Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2be5105e9 | ||
|
|
eb5ff2e46c | ||
|
|
e1269f4daa | ||
|
|
487623e4fb | ||
|
|
eff44c42a1 | ||
|
|
ec13f2c693 | ||
|
|
aa52b62ae7 | ||
|
|
302ee37ca1 | ||
|
|
b7282f9c99 | ||
|
|
2c347d1cc5 | ||
|
|
3dec77bd0a | ||
|
|
ec81922001 | ||
|
|
d7b70b21d1 | ||
|
|
a7cf85dd66 | ||
|
|
791f43986d | ||
|
|
bb22068856 | ||
|
|
209fa3fff1 | ||
|
|
935d23887c | ||
|
|
363f1582c9 | ||
|
|
1a9b47953a | ||
|
|
182ff7facc | ||
|
|
af8968ac01 | ||
|
|
4bd66af0ff | ||
|
|
73826fb4c3 | ||
|
|
2f9407e258 | ||
|
|
534bf0e10a | ||
|
|
df479a0a8d | ||
|
|
1948d2c67f |
@@ -0,0 +1,33 @@
|
|||||||
|
name: Deploy Landing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service && git pull
|
||||||
|
fi
|
||||||
|
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
|
||||||
|
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||||
|
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
|
||||||
|
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||||
|
docker image prune -af
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 511 KiB |
@@ -0,0 +1,154 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { FC, useRef } from 'react';
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion';
|
||||||
|
|
||||||
|
export const CTASection: FC = () => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleVariant: Variants = {
|
||||||
|
hidden: { scale: 0 },
|
||||||
|
visible: {
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentVariant: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:py-8 lg:px-20">
|
||||||
|
<motion.div
|
||||||
|
className="relative max-w-7xl mx-auto bg-white rounded-lg border-2 border-primary-200 py-20 overflow-hidden md:rounded-3xl lg:py-24"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
{/* Background gradients and shapes */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<motion.div
|
||||||
|
className={cn("absolute top-8 -left-14 size-28 bg-gradient-to-tl from-primary-400 to-primary-300 rounded-full",
|
||||||
|
"md:size-52 md:-top-20 md:-left-10 lg:size-72 lg:-top-16 lg:-left-14"
|
||||||
|
)}
|
||||||
|
variants={scaleVariant}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-24 -right-14 size-[89px] bg-gradient-to-b from-primary-400 to-primary-300 rounded-full blur-[1.8px]",
|
||||||
|
"md:size-[182px] md:-right-24 md:blur-[2px] lg:size-[392px] lg:top-16 lg:-right-52 lg:blur-[4px]"
|
||||||
|
)}
|
||||||
|
variants={scaleVariant}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
variants={scaleVariant}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-10 -left-[88px] size-28 bg-primary-200 rounded-full flex justify-center items-center",
|
||||||
|
"md:size-56 md:-bottom-28 md:-left-28 lg:size-[274px] lg:-bottom-24 lg:-left-24"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: isInView ? 1 : 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
className="size-[72px] bg-primary-50 rounded-full md:size-40 lg:size-[200px]"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-primary-400 via-primary-200 opacity-90",
|
||||||
|
"md:h-[22%] md:opacity-85 lg:h-1/4"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.img
|
||||||
|
src="/logos/logo.svg"
|
||||||
|
alt="IMPHNEN Logo"
|
||||||
|
className="relative w-28 mx-auto mb-8 md:w-[186px]"
|
||||||
|
variants={contentVariant}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mb-8 max-w-52 mx-auto md:max-w-[446px] lg:max-w-[688px]">
|
||||||
|
<motion.h1
|
||||||
|
className={cn(
|
||||||
|
"mb-3 text-xl text-center font-semibold leading-tight text-primary-500",
|
||||||
|
"md:text-4xl md:mb-2 lg:text-5xl lg:font-bold"
|
||||||
|
)}
|
||||||
|
variants={contentVariant}
|
||||||
|
>
|
||||||
|
Start Your IT Journey Now!
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
className={cn(
|
||||||
|
"mb-6 text-[15px] font-medium text-center leading-tight text-primary-400",
|
||||||
|
"md:text-2xl md:mb-8 lg:text-[29px]"
|
||||||
|
)}
|
||||||
|
variants={contentVariant}
|
||||||
|
>
|
||||||
|
Jangan biarkan progress mu hanya jadi side story!
|
||||||
|
</motion.p>
|
||||||
|
<motion.p
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-semibold text-center leading-tight text-primary-400",
|
||||||
|
"md:text-[19px] lg:text-[23px] lg:max-w-[628px] lg:mx-auto"
|
||||||
|
)}
|
||||||
|
variants={contentVariant}
|
||||||
|
>
|
||||||
|
Dapatkan mentor terbaik dan gunakan AI untuk belajar lebih cepat. Waktunya jadi protagonist dalam perjalanan IT-mu!
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative max-w-52 mx-auto space-y-3 md:max-w-full md:flex md:gap-x-6 md:justify-center md:space-y-0 md:items-center"
|
||||||
|
variants={contentVariant}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
|
||||||
|
>
|
||||||
|
Mulai Belajar Dengan AI
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
|
||||||
|
>
|
||||||
|
Temukan Mentor
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||||
|
import { Accordion, AccordionOptions } from '@imphnen-frontend-service/ui/molecules'
|
||||||
|
import { FC, useRef } from 'react'
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion'
|
||||||
|
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||||
|
|
||||||
|
const FAQ_DATA: AccordionOptions[] = [
|
||||||
|
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.' },
|
||||||
|
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas untuk mempercepat progres belajarnya.' },
|
||||||
|
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih dari sekadar kursus-kursus AI guide dan mentor berpengalaman, kamu mendapatkan pengalaman belajar yang jauh lebih personal dan efektif.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FAQSection: FC = () => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="relative mx-auto py-4 px-8 overflow-x-clip md:py-8 md:px-[60px] lg:px-20 md:pb-8">
|
||||||
|
{/* Background shapes */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: isInView ? 1 : 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={cn(
|
||||||
|
"absolute -top-10 -right-14 size-60 bg-primary-200 rounded-full justify-center items-center hidden",
|
||||||
|
"md:flex lg:size-[344px] lg:top-48 lg:-right-[88px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: isInView ? 1 : 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
className="size-[168px] bg-primary-50 rounded-full lg:size-64"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="relative mb-6 flex justify-center md:mb-10"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
|
||||||
|
>
|
||||||
|
Frequently Ask Question
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative grid max-w-7xl mx-auto gap-y-7 divide-y-2 divide-primary-200",
|
||||||
|
"md:grid-cols-11 md:gap-x-10 md:divide-y-0 md:divide-x-2 lg:grid-cols-7 lg:gap-x-[72px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.h1
|
||||||
|
className={cn(
|
||||||
|
"pb-7 text-2xl font-semibold text-primary-500 text-center leading-none",
|
||||||
|
"md:pe-10 md:pb-0 md:text-start md:col-span-5 lg:text-4xl lg:col-span-3 lg:pe-[72px]"
|
||||||
|
)}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
Console log for new developers
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col gap-4 md:col-span-6 lg:col-span-4"
|
||||||
|
variants={containerVariants}
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
<For data={FAQ_DATA}>
|
||||||
|
{(props) => (
|
||||||
|
<motion.div key={props.title} variants={childVariants}>
|
||||||
|
<Accordion {...props} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
export function HeroSection() {
|
||||||
|
return (
|
||||||
|
<section className="relative w-full py-20 md:py-28 lg:py-32">
|
||||||
|
{/* Background gradients and shapes */}
|
||||||
|
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="absolute top-10 -left-64 size-[448px] bg-primary-200 rounded-full justify-center items-center hidden lg:flex"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="size-[296px] bg-primary-50 rounded-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="absolute top-3/5 -right-56 size-96 bg-primary-500 rounded-full flex justify-center items-center lg:top-1/2 lg:-right-64 lg:size-[600px]"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="size-64 bg-primary-50 rounded-full lg:size-[400px]"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="absolute -top-28 -left-20 size-[248px] bg-radial from-primary-300 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute top-0 -right-72 size-[480px] bg-radial from-primary-400 rounded-full blur-[80px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-y-10">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="flex flex-col items-center gap-y-6"
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold text-primary-500 md:text-4xl md:font-bold lg:text-5xl">DIMENTORIN</h1>
|
||||||
|
<p className="max-w-52 text-center font-semibold text-xl leading-tight text-primary-500 md:max-w-72 lg:text-4xl lg:max-w-lg">
|
||||||
|
Learn IT smarter with AI & expert mentors!
|
||||||
|
</p>
|
||||||
|
<p className="max-w-3xs text-xs text-neutral-500 text-center font-medium md:max-w-lg md:text-base lg:text-lg lg:max-w-[646px]">
|
||||||
|
Apakah kamu siap untuk grind skill TI-mu ke level Ultimate? Dengan AI sebagai navigator dan mentor profesional sebagai sensei-mu, perjalanan belajarmu akan lebih cepat, efektif, dan penuh EXP!
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} viewport={{ once: true }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
|
||||||
|
>
|
||||||
|
Belajar Dengan AI Sekarang
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||||
|
import { For } from '@imphnen-frontend-service/utils'
|
||||||
|
import { FC, useRef } from 'react'
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion'
|
||||||
|
import { StarFilled } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export const TestimonialSection: FC = () => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="mx-auto py-4 md:py-8 xl:px-20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="flex justify-center"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
|
||||||
|
>
|
||||||
|
Testimonial
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="my-6 text-lg font-semibold mx-auto text-center text-primary-500 md:text-2xl lg:my-10 lg:text-4xl"
|
||||||
|
>
|
||||||
|
Words from our dellow devs
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<div className="scrollbar-hide w-full mx-auto max-w-7xl px-8 overflow-auto md:px-[60px] xl:px-0">
|
||||||
|
<motion.div
|
||||||
|
className="mb-2 flex gap-x-5 min-w-max w-auto md:gap-x-6 xl:min-w-full xl:max-w-7xl xl:grid xl:grid-cols-2"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? 'visible' : 'hidden'}
|
||||||
|
>
|
||||||
|
<For data={Array.from({ length: 2 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
variants={childVariants}
|
||||||
|
className="flex flex-col gap-6 p-6 w-52 bg-white rounded-xl shadow-md md:w-xl md:flex-row md:gap-8 xl:w-full"
|
||||||
|
>
|
||||||
|
<div className="w-full aspect-[8/7] rounded-lg overflow-hidden md:w-40 md:aspect-[7/6] lg:aspect-square">
|
||||||
|
<img
|
||||||
|
src="/image/testimonial.webp"
|
||||||
|
alt="Testimonial"
|
||||||
|
className="w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="mb-3 font-semibold text-primary-500 md:text-lg lg:text-2xl">
|
||||||
|
Riko, Junior Web Developer
|
||||||
|
</h3>
|
||||||
|
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
|
||||||
|
Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!
|
||||||
|
</p>
|
||||||
|
<motion.div className="w-max" whileHover={{ scale: 1.1, rotate: -3 }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
className="bg-primary-50 flex items-center gap-x-2 h-auto px-2 py-1 hover:bg-primary-50"
|
||||||
|
>
|
||||||
|
<StarFilled className="md:text-lg" />
|
||||||
|
<span>
|
||||||
|
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">4.8</span>
|
||||||
|
<span className="text-[10px] text-primary-300 md:text-sm md:font-medium lg:text-base">/5.0</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC, useRef } from "react";
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion'
|
||||||
|
|
||||||
|
const OFFERS: { title: string; description: string; image: string }[] = [
|
||||||
|
{ title: "AI-Powered Learning", description: "Level Up Instantly!", image: "/image/what-we-offer/ai-powered-learning.webp" },
|
||||||
|
{ title: "1-on-1 Mentoring", description: "Sensei dari Dunia Nyata!", image: "/image/what-we-offer/1-on-1-mentoring.webp" },
|
||||||
|
{ title: "Community & Resources", description: "Connect dengan Fellow Devs!", image: "/image/what-we-offer/community.webp" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const WhatWeOfferSection: FC = () => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="w-full mx-auto px-8 pb-4 md:px-[60px] lg:px-20 lg:pt-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="mb-6 flex justify-center lg:mb-10"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
|
||||||
|
>
|
||||||
|
Apa yang Kami Tawarkan
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="grid gap-6 max-w-7xl mx-auto md:grid-cols-3"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
<For data={OFFERS}>
|
||||||
|
{(offer) => (
|
||||||
|
<motion.div
|
||||||
|
key={offer.title}
|
||||||
|
className="relative aspect-[16/10] overflow-hidden rounded-lg md:aspect-[24/35] lg:aspect-[6/7]"
|
||||||
|
variants={childVariants}
|
||||||
|
>
|
||||||
|
<img src={offer.image} alt={offer.title} className="size-full object-cover" />
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
|
||||||
|
>
|
||||||
|
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
|
||||||
|
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { HeroSection } from './_components/hero-section';
|
||||||
|
import { WhatWeOfferSection } from './_components/what-we-offer-section';
|
||||||
|
import { TestimonialSection } from './_components/testimonial-section';
|
||||||
|
import { FAQSection } from './_components/faq-section';
|
||||||
|
import { CTASection } from './_components/cta-section';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroSection />
|
||||||
|
<WhatWeOfferSection />
|
||||||
|
<TestimonialSection />
|
||||||
|
<FAQSection />
|
||||||
|
<CTASection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { DiscordOutlined, GithubOutlined, InstagramOutlined } from '@ant-design/icons';
|
||||||
|
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||||
|
import React, { FC, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion';
|
||||||
|
import { SteamIcon } from '../../_components/icons';
|
||||||
|
|
||||||
|
const PAGES: { label: string; href: string }[] = [
|
||||||
|
{ label: "Home", href: "/" },
|
||||||
|
{ label: 'Mentoring', href: '/mentoring' },
|
||||||
|
{ label: 'Resources', href: '/resources' },
|
||||||
|
{ label: 'Articles', href: '/articles' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const COMMUNITY: { label: string; href: string; icon: React.ReactNode }[] = [
|
||||||
|
{ label: 'Discord', href: 'https://discord.gg/imphnen', icon: <DiscordOutlined /> },
|
||||||
|
{ label: 'Steam', href: 'https://steamcommunity.com/groups/IMPHNEN', icon: <SteamIcon /> },
|
||||||
|
{ label: 'Github', href: 'https://github.com/IMPHNEN', icon: <GithubOutlined /> },
|
||||||
|
{ label: 'Instagram', href: 'https://www.instagram.com/imphnen.dev', icon: <InstagramOutlined /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Footer: FC = () => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: { opacity: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mascotVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20, rotate: -12 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mascotTextVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
delay: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:pt-8 md:pb-16 lg:px-20 lg:pb-20">
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"relative max-w-7xl mx-auto px-10 py-8 bg-primary-500 text-primary-50 grid gap-10 rounded-lg",
|
||||||
|
"lg:grid-cols-3 lg:rounded-xl"
|
||||||
|
)}
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
{/* Mascot */}
|
||||||
|
<motion.div
|
||||||
|
className="hidden absolute bottom-0 right-0 size-40 md:block lg:size-72"
|
||||||
|
variants={mascotVariants}
|
||||||
|
>
|
||||||
|
<motion.img
|
||||||
|
src="/image/mascot-character.webp"
|
||||||
|
alt="IMPHNEN Mascot"
|
||||||
|
className="w-full h-auto absolute inset-x-0 bottom-0"
|
||||||
|
/>
|
||||||
|
<motion.img
|
||||||
|
src="/image/mascot-text.webp"
|
||||||
|
alt="IMPHNEN Mascot"
|
||||||
|
className="w-4/6 absolute top-0 left-0"
|
||||||
|
variants={mascotTextVariants}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="text-center lg:text-start lg:mb-16">
|
||||||
|
<motion.img
|
||||||
|
src="/logos/logo.svg"
|
||||||
|
alt="IMPHNEN Logo"
|
||||||
|
className="w-[84px] mb-6 mx-auto md:w-32 lg:w-40 lg:ms-0"
|
||||||
|
variants={childVariants}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.h4 className="mb-2 font-semibold text-[15px] md:mb-2.5 md:text-xl" variants={childVariants}>
|
||||||
|
Dimentorin by IMPHNEN
|
||||||
|
</motion.h4>
|
||||||
|
<motion.p className="font-medium text-xs md:text-[15px] md:max-w-[232px] md:mx-auto lg:ms-0" variants={childVariants}>
|
||||||
|
Karena Belajar IT Itu Harusnya Se-Seru Anime Favoritmu!
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.p className="relative hidden text-xs font-medium text-[15px] lg:block" variants={childVariants}>
|
||||||
|
© IMPHNEN {new Date().getFullYear()} All Rights Reserved
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div className="relative grid gap-10 md:flex md:justify-center lg:gap-x-20" variants={childVariants}>
|
||||||
|
<div className="text-center text-xs md:text-[15px] lg:text-start">
|
||||||
|
<h4 className="mb-5 font-medium md:mb-7 lg:text-xl">Pages</h4>
|
||||||
|
<ul className="font-semibold space-y-5">
|
||||||
|
<For data={PAGES}>
|
||||||
|
{({ label, href }) => (
|
||||||
|
<li key={label}>
|
||||||
|
<Link to={href}>{label}</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-xs md:text-[15px] lg:text-start">
|
||||||
|
<h4 className="mb-5 font-medium md:mb-7 lg:text-xl">Join Our Community</h4>
|
||||||
|
<ul className="font-semibold space-y-5">
|
||||||
|
<For data={COMMUNITY}>
|
||||||
|
{({ label, href, icon }) => (
|
||||||
|
<li key={label}>
|
||||||
|
<Link to={href} target="_blank" className="flex items-center justify-center gap-x-3 lg:justify-start">
|
||||||
|
<span className="text-primary-900">{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.p className="relative text-center text-xs font-medium md:text-[15px] lg:hidden" variants={childVariants}>
|
||||||
|
© IMPHNEN {new Date().getFullYear()} All Rights Reserved
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { cn, For, Show } from "@imphnen-frontend-service/utils";
|
||||||
|
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
|
||||||
|
import { FC, useState } from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
const MENUS: { label: string; href: string }[] = [
|
||||||
|
{ label: "Home", href: "/" },
|
||||||
|
{ label: 'Mentoring', href: '/mentoring' },
|
||||||
|
{ label: 'Resources', href: '/resources' },
|
||||||
|
{ label: 'Articles', href: '/articles' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Header: FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const { scrollY } = useScroll()
|
||||||
|
|
||||||
|
const [expandMenu, setExpandMenu] = useState(false);
|
||||||
|
const [show, setShow] = useState(true)
|
||||||
|
|
||||||
|
const headerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: -100 },
|
||||||
|
show: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||||
|
const prev = scrollY.getPrevious() || 0
|
||||||
|
if (latest > prev && latest > 100) setShow(false)
|
||||||
|
else setShow(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky overflow-hidden">
|
||||||
|
<motion.header
|
||||||
|
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
|
||||||
|
aria-roledescription="nav"
|
||||||
|
variants={headerVariants}
|
||||||
|
animate={show ? "show" : "hidden"}
|
||||||
|
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between p-4 md:px-8 md:py-2.5">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src="/logos/simple.svg"
|
||||||
|
alt="IMPHNEN Logo"
|
||||||
|
className="h-8 md:h-10 lg:h-[52px] w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center py-2 fixed top-24 bg-white shadow-lg rounded-lg transition-all duration-300",
|
||||||
|
"md:top-32 lg:static lg:py-0 lg:flex-row lg:bg-transparent lg:shadow-none lg:gap-x-4",
|
||||||
|
!expandMenu ? "-right-96" : "right-8 md:right-[60px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<For data={MENUS}>
|
||||||
|
{(menu) => (
|
||||||
|
<Button
|
||||||
|
key={menu.label}
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
className={cn(
|
||||||
|
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
|
||||||
|
location.pathname === menu.href && "text-primary-500 hover:text-primary-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link to={menu.href}>{menu.label}</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Button type="button" className="px-12 py-1 md:hidden">Login</Button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
className={cn("transition-transform duration-300 rotate-0 lg:hidden", expandMenu && "rotate-90")}
|
||||||
|
onClick={() => setExpandMenu(prev => !prev)}
|
||||||
|
>
|
||||||
|
<Show condition={!expandMenu} fallback={<CloseOutlined />}>
|
||||||
|
<MenuOutlined />
|
||||||
|
</Show>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Header } from "./_components/header";
|
||||||
|
import { Footer } from "./_components/footer";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-primary-50">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 z-0">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './steam';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
export const SteamIcon: FC<React.SVGProps<SVGSVGElement>> = ({ ...props }) => {
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path d="M9 0.875C4.52 0.875 0.875 4.52 0.875 9C0.875 13.48 4.52 17.125 9 17.125C13.48 17.125 17.125 13.48 17.125 9C17.125 4.52 13.48 0.875 9 0.875ZM9 2.125C12.7906 2.125 15.875 5.20937 15.875 9C15.875 12.7906 12.7906 15.875 9 15.875C5.80687 15.875 3.12438 13.6838 2.35438 10.7288L4.64563 11.71C4.69576 12.167 4.91262 12.5896 5.25474 12.8968C5.59686 13.2039 6.04021 13.3742 6.5 13.375C6.99728 13.375 7.47419 13.1775 7.82583 12.8258C8.17746 12.4742 8.375 11.9973 8.375 11.5C8.375 11.48 8.36937 11.4606 8.36875 11.4412L10.98 9.615C11.6235 9.5895 12.2321 9.31606 12.6786 8.85193C13.125 8.38781 13.3745 7.76898 13.375 7.125C13.375 6.46196 13.1116 5.82607 12.6428 5.35723C12.1739 4.88839 11.538 4.625 10.875 4.625C10.2312 4.62579 9.61273 4.8755 9.14886 5.32188C8.685 5.76827 8.41174 6.37674 8.38625 7.02L6.55875 9.63125C6.53875 9.63062 6.52 9.625 6.5 9.625C6.08563 9.625 5.70625 9.76375 5.395 9.99125L2.14562 8.59813C2.35625 4.995 5.345 2.125 9 2.125ZM10.875 5.25C11.3723 5.25 11.8492 5.44754 12.2008 5.79917C12.5525 6.15081 12.75 6.62772 12.75 7.125C12.75 7.62228 12.5525 8.09919 12.2008 8.45083C11.8492 8.80246 11.3723 9 10.875 9C10.3777 9 9.90081 8.80246 9.54917 8.45083C9.19754 8.09919 9 7.62228 9 7.125C9 6.62772 9.19754 6.15081 9.54917 5.79917C9.90081 5.44754 10.3777 5.25 10.875 5.25ZM10.875 5.875C10.5435 5.875 10.2255 6.0067 9.99112 6.24112C9.7567 6.47554 9.625 6.79348 9.625 7.125C9.625 7.45652 9.7567 7.77446 9.99112 8.00888C10.2255 8.2433 10.5435 8.375 10.875 8.375C11.2065 8.375 11.5245 8.2433 11.7589 8.00888C11.9933 7.77446 12.125 7.45652 12.125 7.125C12.125 6.79348 11.9933 6.47554 11.7589 6.24112C11.5245 6.0067 11.2065 5.875 10.875 5.875ZM6.5 10.25C6.72467 10.2495 6.94532 10.3095 7.13875 10.4238C7.33217 10.5381 7.49122 10.7024 7.59915 10.8995C7.70709 11.0965 7.75992 11.319 7.75208 11.5436C7.74425 11.7681 7.67605 11.9864 7.55466 12.1754C7.43326 12.3645 7.26315 12.5173 7.06224 12.6178C6.86132 12.7184 6.63702 12.7629 6.41293 12.7468C6.18884 12.7306 5.97325 12.6543 5.78884 12.526C5.60443 12.3977 5.45801 12.222 5.365 12.0175L5.85437 12.2275C6.08293 12.325 6.34082 12.328 6.57159 12.2359C6.80235 12.1437 6.98719 11.9638 7.08563 11.7356C7.18341 11.507 7.18639 11.249 7.09391 11.0182C7.00144 10.7874 6.82107 10.6028 6.5925 10.505L6.1375 10.31C6.25313 10.2744 6.37313 10.25 6.5 10.25Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useSendOTP } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
export const useResendOtpHook = () => {
|
||||||
|
|
||||||
|
const { resendOTP, isLoading } = useSendOTP();
|
||||||
|
|
||||||
|
return {
|
||||||
|
resendOTP
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -51,6 +51,11 @@ export const Components: FC = (): ReactElement => {
|
|||||||
Daftar Disini
|
Daftar Disini
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='text-center w-full mb-1'>
|
||||||
|
<a href="/auth/register-mentor" className="text-primary-500 font-medium">
|
||||||
|
Daftar Sebagai Mentor
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<div className="flex-grow border-t border-blue-400 opacity-50"></div>
|
<div className="flex-grow border-t border-blue-400 opacity-50"></div>
|
||||||
<span className="px-3 text-blue-400">Or</span>
|
<span className="px-3 text-blue-400">Or</span>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<main className="bg-primary-50 min-h-screen">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { Button, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [ step, setStep ] = useState(1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
||||||
|
<div className="bg-white xl:min-w-[1130px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
||||||
|
<RegisterResetBanner />
|
||||||
|
<div className="xl:border-2 xl:border-primary-500/50 xl:w-[726px] rounded-lg py-[32px] px-[48px] flex justify-center">
|
||||||
|
<div className="xl:w-[714px]">
|
||||||
|
<Button
|
||||||
|
className='xl:hidden gap-3'
|
||||||
|
variant='secondary'
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<RegisterMentorStep step={step} />
|
||||||
|
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
|
||||||
|
Register
|
||||||
|
</h3>
|
||||||
|
<h5 className="text-primary-500 font-medium">
|
||||||
|
Welcome to the team, Mentor - powered by IMPHNEN
|
||||||
|
</h5>
|
||||||
|
<form>
|
||||||
|
<div className={`${step !== 1 ? 'hidden' : ''}`}>
|
||||||
|
<div className='mt-7 mb-2'>
|
||||||
|
<InputField
|
||||||
|
label="Nama Lengkap"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Nama Lengkap"
|
||||||
|
name={'fullname'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SelectField
|
||||||
|
label="Jenis Kelamin"
|
||||||
|
defaultValue=""
|
||||||
|
size="lg"
|
||||||
|
error=""
|
||||||
|
>
|
||||||
|
<option value="" disabled hidden>
|
||||||
|
Pilih Jenis Kelamin
|
||||||
|
</option>
|
||||||
|
<option value="laki-laki">Laki - Laki</option>
|
||||||
|
<option value="perempuan">Perempuan</option>
|
||||||
|
</SelectField>
|
||||||
|
<div className='my-2'>
|
||||||
|
<InputField
|
||||||
|
label="No Telp/Whatsapp Only"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Nama Lengkap"
|
||||||
|
name={'fullname'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SelectField
|
||||||
|
label="Domisili"
|
||||||
|
defaultValue=""
|
||||||
|
size="lg"
|
||||||
|
error=""
|
||||||
|
>
|
||||||
|
<option value="" disabled hidden>
|
||||||
|
Pilih Domisili
|
||||||
|
</option>
|
||||||
|
<option value="aceh">Aceh</option>
|
||||||
|
<option value="jawa-selatan">Jawa Selatan</option>
|
||||||
|
</SelectField>
|
||||||
|
<div className='mt-2'>
|
||||||
|
<SelectField
|
||||||
|
label="Pendidikan Terakhir"
|
||||||
|
defaultValue=""
|
||||||
|
size="lg"
|
||||||
|
error=""
|
||||||
|
>
|
||||||
|
<option value="" disabled hidden>
|
||||||
|
Apa pendidikan terakhir senpai
|
||||||
|
</option>
|
||||||
|
<option value="SMA">SMA</option>
|
||||||
|
<option value="S1">S1</option>
|
||||||
|
<option value="S2">S2</option>
|
||||||
|
<option value="S3">S3</option>
|
||||||
|
</SelectField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`${step !== 2 ? 'hidden' : ''}`}>
|
||||||
|
<div className='mt-7 mb-2 gap-2 flex flex-col'>
|
||||||
|
<InputField
|
||||||
|
label="Perusahaan Saat Ini"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Masukkan Perusahaan tempat kerja, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
||||||
|
name={'fullname'}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Masukan jabatan senpai di perusahaan"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Masukan jabatan senpai di perusahaan"
|
||||||
|
name={'fullname'}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Topik Keahlian Yang Ingin Diajarkan"
|
||||||
|
defaultValue=""
|
||||||
|
size="lg"
|
||||||
|
error=""
|
||||||
|
>
|
||||||
|
<option value="" disabled hidden>
|
||||||
|
Apa yang ingin anda ajarkan senpaii
|
||||||
|
</option>
|
||||||
|
<option value="Frontend Developer">Frontend Developer</option>
|
||||||
|
<option value="Backend Developer">Backend Developer</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</SelectField>
|
||||||
|
<InputField
|
||||||
|
label="Jika Mengisi “Other”, Tulis Topik yang Kamu Kuasai"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Tolong isi jika tidak ada di pilihan senpai"
|
||||||
|
name={'fullname'}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Skill Utama yang Kamu Miliki (boleh lebih dari satu)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Isi Skill nya dong senpai"
|
||||||
|
name={'fullname'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`${step !== 3 ? 'hidden' : ''}`}>
|
||||||
|
<div className='mt-7 mb-2 gap-2 flex flex-col'>
|
||||||
|
<SelectField
|
||||||
|
label="Durasi Sesi Mentoring"
|
||||||
|
defaultValue=""
|
||||||
|
size="lg"
|
||||||
|
error=""
|
||||||
|
>
|
||||||
|
<option value="" disabled hidden>
|
||||||
|
Placeholder
|
||||||
|
</option>
|
||||||
|
</SelectField>
|
||||||
|
<SelectField
|
||||||
|
label="Format Pengajaran yang Anda Sediakan"
|
||||||
|
defaultValue=""
|
||||||
|
size="lg"
|
||||||
|
error=""
|
||||||
|
>
|
||||||
|
<option value="" disabled hidden>
|
||||||
|
Placeholder
|
||||||
|
</option>
|
||||||
|
</SelectField>
|
||||||
|
<InputField
|
||||||
|
type="file"
|
||||||
|
label="Unggah Curiculum Vitae"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Unggah Portofolio"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Google Drive, Behance, GitHub, Notion, dan lainnya"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
type='number'
|
||||||
|
label="Honorarium yang Diharapkan per Sesi (Online) Durasi rata-rata sesi: 2,5–3 jam"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Isi Nominalnya dong senpaiii!!!"
|
||||||
|
/>
|
||||||
|
<h6 className='text-xs'>Silahkan isi dalam angka (Contoh:500.000)</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`${step !== 4 ? 'hidden' : ''}`}>
|
||||||
|
<div className='mt-7 mb-2 gap-2 flex flex-col'>
|
||||||
|
<InputField
|
||||||
|
type='email'
|
||||||
|
label="Email"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="isi emailnya dong senpai!!!"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
type='password'
|
||||||
|
label="Buat Password"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Buat Password"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
type='password'
|
||||||
|
label="Buat Password"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Buat Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end mt-4 gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='secondary'
|
||||||
|
onClick={() => setStep(step - 1)}
|
||||||
|
type='button'
|
||||||
|
className={`${step === 1 ? 'hidden' : ''}`}
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={() => setStep(step + 1)}
|
||||||
|
type='button'
|
||||||
|
className={`${step === 4 ? 'hidden' : ''}`}
|
||||||
|
>
|
||||||
|
Lanjutkan
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={() => setStep(step + 1)}
|
||||||
|
type='button'
|
||||||
|
className={`${step === 4 ? '' : 'hidden'}`}
|
||||||
|
>
|
||||||
|
Selesaikan
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<main className="bg-primary-50 min-h-screen">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { FC, ReactElement, useEffect } from 'react';
|
||||||
|
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
||||||
|
<div className="bg-white xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
||||||
|
<RegisterResetBanner />
|
||||||
|
<div className="border-2 border-primary-500/50 xl:w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50">
|
||||||
|
<div className="xl:w-[704px] flex flex-col justify-center items-center">
|
||||||
|
<h2 className="text-4xl text-primary-500 font-semibold">
|
||||||
|
Register Akun Mentor Berhasil
|
||||||
|
</h2>
|
||||||
|
<h4 className="mt-3 text-primary-500 font-medium text-xl text-center">
|
||||||
|
Data kamu telah kami terima dan sedang dalam proses verifikasi.
|
||||||
|
</h4>
|
||||||
|
<svg className="mt-10" width="148" height="148" viewBox="0 0 148 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="74" cy="74" r="73.6348" fill="#42CDF8"/>
|
||||||
|
<rect x="23.5078" y="24.8463" width="99.646" height="99.646" rx="49.823" fill="#0185D0"/>
|
||||||
|
<circle cx="73.3284" cy="74.6689" r="33.11" transform="rotate(150 73.3284 74.6689)" stroke="#F0F8FF" stroke-width="1.1825"/>
|
||||||
|
<path d="M44.6436 91.2314C42.1053 92.6969 41.2086 95.9711 43.0096 98.2836C46.1026 102.255 49.9569 105.594 54.3651 108.095C60.3178 111.473 67.0644 113.199 73.9077 113.096C80.7511 112.993 87.4427 111.065 93.2912 107.51C97.6222 104.878 101.374 101.424 104.347 97.3614C106.077 94.9958 105.083 91.75 102.501 90.3615C99.9201 88.9729 96.7369 89.9826 94.884 92.2537C92.8925 94.6949 90.4923 96.7901 87.7782 98.4398C83.545 101.013 78.7015 102.409 73.7482 102.483C68.7949 102.558 63.9116 101.308 59.6029 98.8634C56.8404 97.2961 54.3784 95.2739 52.3144 92.8938C50.3941 90.6794 47.182 89.7658 44.6436 91.2314Z" fill="#F0F8FF"/>
|
||||||
|
</svg>
|
||||||
|
<h4 className="mt-10 text-primary-500 font-medium text-xl text-center">
|
||||||
|
Mohon tunggu maksimal 2 hari kerja untuk proses aktivasi akun. Kami akan menghubungi kamu jika proses telah selesai.
|
||||||
|
</h4>
|
||||||
|
<a href="/auth/login">
|
||||||
|
<Button className="gap-3 mt-10" size="lg">
|
||||||
|
Kembali
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<main className="bg-primary-50 min-h-screen">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { FC, ReactElement, useEffect } from 'react';
|
||||||
|
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
||||||
|
<div className="bg-white xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
||||||
|
<RegisterResetBanner />
|
||||||
|
<div className="border-2 border-primary-500/50 xl:w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50">
|
||||||
|
<div className="xl:w-[704px] flex flex-col justify-center items-center">
|
||||||
|
<h2 className="text-4xl text-primary-500 font-semibold">
|
||||||
|
Register Akun Mentor Berhasil
|
||||||
|
</h2>
|
||||||
|
<h4 className="mt-3 text-primary-500 font-medium text-xl">
|
||||||
|
Akun Kamu Telah Berhasil Diverifikasi!
|
||||||
|
</h4>
|
||||||
|
<svg
|
||||||
|
width="198"
|
||||||
|
height="198"
|
||||||
|
viewBox="0 0 198 198"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="mt-10"
|
||||||
|
>
|
||||||
|
<circle cx="99" cy="99" r="99" fill="#BCF8B0" />
|
||||||
|
<rect
|
||||||
|
x="31.1152"
|
||||||
|
y="32.9141"
|
||||||
|
width="133.971"
|
||||||
|
height="133.971"
|
||||||
|
rx="66.9857"
|
||||||
|
fill="#35BA43"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M127.935 75.8846H122.722C121.991 75.8846 121.297 76.2202 120.85 76.7945L90.0997 115.748L75.355 97.0657C75.1319 96.7825 74.8476 96.5535 74.5234 96.3959C74.1992 96.2384 73.8435 96.1563 73.483 96.1558H68.2697C67.77 96.1558 67.4941 96.7301 67.7999 97.1179L88.2277 122.998C89.1824 124.206 91.0171 124.206 91.9792 122.998L128.405 76.8392C128.711 76.4589 128.435 75.8846 127.935 75.8846Z"
|
||||||
|
fill="#E0FBD8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h4 className="mt-10 text-primary-500 font-medium text-xl text-center">
|
||||||
|
Kamu sekarang bisa login dan mulai menggunakan layanan kami. Selamat bergabung! SENPAIIII
|
||||||
|
</h4>
|
||||||
|
<a href="/auth/login">
|
||||||
|
<Button className="gap-3 mt-10" size="lg">
|
||||||
|
Masuk Jalur Mentor Isekai
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -1,12 +1,38 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||||
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
|
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
|
||||||
import { useOtpHook } from '../../../_hooks/use-otp';
|
import { useOtpHook } from '../../../_hooks/use-otp';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useResendOtpHook } from '../../../_hooks/use-resend-otp';
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
const { form, onSubmit, isLoading } = useOtpHook()
|
const { form, onSubmit, isLoading } = useOtpHook()
|
||||||
const [ searchParams ] = useSearchParams()
|
const [ searchParams ] = useSearchParams()
|
||||||
|
const [ disabled, setDisabled] = useState(true);
|
||||||
|
const [ timeLeft, setTimeLeft ] = useState(5 * 60);
|
||||||
|
const { resendOTP } = useResendOtpHook()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTimeLeft(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setDisabled(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
|
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
|
||||||
@@ -26,6 +52,13 @@ export const Components: FC = (): ReactElement => {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
/>
|
/>
|
||||||
|
<Button type="button" className="mt-2" disabled={disabled} onClick={
|
||||||
|
() => {
|
||||||
|
resendOTP({email: searchParams.get("email") || ""})
|
||||||
|
setDisabled(true)
|
||||||
|
setTimeLeft(5 * 60)
|
||||||
|
}
|
||||||
|
}>Kirim ulang otp {disabled? `(${formatTime(timeLeft)})`: ""}</Button>
|
||||||
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
|
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { Navbar } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement } from 'react';
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
return <>Hallo</>;
|
return (
|
||||||
|
<div className='bg-blue-50'>
|
||||||
|
<Navbar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Components;
|
export default Components;
|
||||||
|
|||||||
@@ -119,3 +119,9 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
add404PageToRoutesChildren,
|
add404PageToRoutesChildren,
|
||||||
addErrorElementToRoutes,
|
addErrorElementToRoutes,
|
||||||
convertPagesToRoute,
|
convertPagesToRoute,
|
||||||
|
ModalLoginProvider,
|
||||||
QueryProvider,
|
QueryProvider,
|
||||||
} from '@imphnen-frontend-service/utils';
|
} from '@imphnen-frontend-service/utils';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
@@ -35,8 +36,10 @@ if (!rootElement) throw new Error('Failed to find the root element');
|
|||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<Toaster position="top-right" />
|
<ModalLoginProvider>
|
||||||
<RouterProvider router={router} />
|
<Toaster position="top-right" />
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ModalLoginProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const mappingPublicRoutes = [
|
|||||||
'/auth/register/otp',
|
'/auth/register/otp',
|
||||||
'/auth/register/success',
|
'/auth/register/success',
|
||||||
'/auth/new-password',
|
'/auth/new-password',
|
||||||
|
'/auth/register-mentor',
|
||||||
|
'/auth/register-mentor/pending',
|
||||||
|
'/auth/register-mentor/success'
|
||||||
];
|
];
|
||||||
|
|
||||||
const mappingRoutePermissions = [
|
const mappingRoutePermissions = [
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
NEXT_PUBLIC_API_URL=
|
NEXT_PUBLIC_API_URL=
|
||||||
|
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITEKEY=
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
const { composePlugins, withNx } = require('@nx/next');
|
const { composePlugins, withNx } = require('@nx/next');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
|
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
nx: { svgr: false },
|
nx: { svgr: false },
|
||||||
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = composePlugins(withNx)(nextConfig);
|
module.exports = composePlugins(withNx)(nextConfig);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SiCplusplus,
|
||||||
|
SiCss3,
|
||||||
|
SiGo,
|
||||||
|
SiHtml5,
|
||||||
|
SiJavascript,
|
||||||
|
SiPhp,
|
||||||
|
SiPython,
|
||||||
|
SiRuby,
|
||||||
|
SiRust,
|
||||||
|
SiSwift,
|
||||||
|
SiTypescript,
|
||||||
|
} from 'react-icons/si';
|
||||||
|
|
||||||
|
export function AnimatedBackground() {
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const iconColorMap = useMemo(
|
||||||
|
() => [
|
||||||
|
{ Icon: SiJavascript, color: '#F7DF1E' },
|
||||||
|
{ Icon: SiTypescript, color: '#3178C6' },
|
||||||
|
{ Icon: SiPython, color: '#3776AB' },
|
||||||
|
{ Icon: SiCplusplus, color: '#00599C' },
|
||||||
|
{ Icon: SiRuby, color: '#CC342D' },
|
||||||
|
{ Icon: SiSwift, color: '#F05138' },
|
||||||
|
{ Icon: SiRust, color: '#000000' },
|
||||||
|
{ Icon: SiGo, color: '#00ADD8' },
|
||||||
|
{ Icon: SiPhp, color: '#777BB4' },
|
||||||
|
{ Icon: SiHtml5, color: '#E34F26' },
|
||||||
|
{ Icon: SiCss3, color: '#1572B6' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRandom = (min: number, max: number) =>
|
||||||
|
Math.random() * (max - min) + min;
|
||||||
|
|
||||||
|
const floatingIcons = useMemo(() => {
|
||||||
|
if (!isClient) return [];
|
||||||
|
|
||||||
|
return Array.from({ length: 40 }).map((_, i) => {
|
||||||
|
const { Icon, color } = iconColorMap[i % iconColorMap.length];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
top: `${getRandom(0, 100)}%`,
|
||||||
|
left: `${getRandom(0, 100)}%`,
|
||||||
|
fontSize: `${getRandom(16, 32)}px`,
|
||||||
|
color: color,
|
||||||
|
opacity: getRandom(0.1, 0.2),
|
||||||
|
rotate: getRandom(-180, 180),
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
y: [0, getRandom(-100, 100), 0],
|
||||||
|
x: [0, getRandom(-50, 50), 0],
|
||||||
|
rotate: getRandom(-180, 180),
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: getRandom(15, 25),
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: 'loop',
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="h-full w-full" />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [isClient, iconColorMap]);
|
||||||
|
|
||||||
|
if (!isClient) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-0 overflow-hidden">{floatingIcons}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export async function fetchPostverifyTurnstile(
|
||||||
|
token: string,
|
||||||
|
remoteIp?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
secret: String(process.env.TURNSTILE_SECRET_KEY),
|
||||||
|
response: token,
|
||||||
|
});
|
||||||
|
if (remoteIp) {
|
||||||
|
params.append('remoteip', remoteIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Turnstile verify HTTP error', res.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as TurnstileVerifyResponse;
|
||||||
|
if (!data.success) {
|
||||||
|
console.warn('Turnstile failure', data['error-codes']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnstileVerifyResponse {
|
||||||
|
success: boolean;
|
||||||
|
challenge_ts: string;
|
||||||
|
hostname: string;
|
||||||
|
'error-codes'?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
|
import {
|
||||||
|
forgotPasswordValidationSchema,
|
||||||
|
ForgotPasswordValidationType,
|
||||||
|
} from '../_validation/forgot-password-validation';
|
||||||
|
|
||||||
|
export async function ForgotPasswordAction(
|
||||||
|
request: ForgotPasswordValidationType
|
||||||
|
) {
|
||||||
|
const validRequest = forgotPasswordValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
|
validRequest.token,
|
||||||
|
remoteIp
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
|
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
|
||||||
|
body: {
|
||||||
|
email: validRequest.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@components';
|
||||||
|
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { LuLoader } from 'react-icons/lu';
|
||||||
|
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
||||||
|
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
||||||
|
import { ForgotPasswordValidationType } from '../_validation/forgot-password-validation';
|
||||||
|
|
||||||
|
export function ForgotPasswordForm() {
|
||||||
|
const ref = useRef<TurnstileInstance | null>(null);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<number>(1);
|
||||||
|
const [emailValue, setEmailValue] = useState<string>('');
|
||||||
|
|
||||||
|
const form = useFormForgotPassword();
|
||||||
|
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
||||||
|
|
||||||
|
const handleFirstStep = (values: ForgotPasswordValidationType) => {
|
||||||
|
setEmailValue(values.email);
|
||||||
|
setStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondStep = () => {
|
||||||
|
mutate({ email: emailValue, token: form.getValues('token') });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={
|
||||||
|
step === 1
|
||||||
|
? form.handleSubmit(handleFirstStep)
|
||||||
|
: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSecondStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
className="w-full space-y-4"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
|
{(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Turnstile
|
||||||
|
ref={ref}
|
||||||
|
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||||
|
onSuccess={(token) => form.setValue('token', token)}
|
||||||
|
options={{ theme: 'light', size: 'flexible', language: 'id' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || (step === 2 && !form.watch('token'))}
|
||||||
|
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<LuLoader className="h-5 w-5 animate-spin" />
|
||||||
|
) : step === 1 ? (
|
||||||
|
'Selanjutnya'
|
||||||
|
) : (
|
||||||
|
'Reset password'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
forgotPasswordValidationSchema,
|
||||||
|
type ForgotPasswordValidationType,
|
||||||
|
} from '../_validation/forgot-password-validation';
|
||||||
|
|
||||||
|
export function useFormForgotPassword() {
|
||||||
|
return useForm<ForgotPasswordValidationType>({
|
||||||
|
resolver: zodResolver(forgotPasswordValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ForgotPasswordAction } from '../_actions/forgot-password-action';
|
||||||
|
import { useFormForgotPassword } from './use-form-forgot-password';
|
||||||
|
|
||||||
|
export function usePostForgotPassowrd(
|
||||||
|
form: ReturnType<typeof useFormForgotPassword>
|
||||||
|
) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ForgotPasswordAction,
|
||||||
|
onSuccess: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.success(message);
|
||||||
|
},
|
||||||
|
onError: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const forgotPasswordValidationSchema = z.object({
|
||||||
|
email: z.string().email({ message: 'Format email tidak valid' }),
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
export type ForgotPasswordValidationType = z.infer<
|
||||||
|
typeof forgotPasswordValidationSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { ForgotPasswordForm } from './_components/forgot-password-form';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'IMPHNEN - Signin',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LogoSimple />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||||
|
Reset password akunmu
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ForgotPasswordForm />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { baiJamjureeFont } from '@/lib/fonts';
|
||||||
|
import { Card } from '@components';
|
||||||
|
import { cn } from '@utils';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { AnimatedBackground } from './_components/animated-background';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
baiJamjureeFont.className,
|
||||||
|
'bg-background/20 relative flex min-h-[100dvh] items-center justify-center p-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="z-10 w-full">
|
||||||
|
<Card className="bg-background/90 mx-auto w-full max-w-[360px] p-6 shadow-xl backdrop-blur-lg sm:max-w-md sm:p-8">
|
||||||
|
<div className="flex flex-col items-center space-y-6">
|
||||||
|
<div className="w-full space-y-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import {
|
||||||
|
resetPasswordValidationSchema,
|
||||||
|
ResetPasswordValidationSchema,
|
||||||
|
} from '../_validation/reset-password-validation';
|
||||||
|
|
||||||
|
export async function resetPasswordAction(
|
||||||
|
request: ResetPasswordValidationSchema
|
||||||
|
) {
|
||||||
|
const validRequest = resetPasswordValidationSchema.parse(request);
|
||||||
|
|
||||||
|
if (validRequest.confirm_password !== validRequest.confirm_password) {
|
||||||
|
throw new Error('Password missmatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await fetcher.POST('/v1/auth/new-password', {
|
||||||
|
body: {
|
||||||
|
password: validRequest.password,
|
||||||
|
token: validRequest.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@components';
|
||||||
|
import { LuLoaderCircle } from 'react-icons/lu';
|
||||||
|
import { usePostResetPassword } from '../_hooks/use-post-reset-password';
|
||||||
|
import { useResetPasswordForm } from '../_hooks/use-reset-password-form';
|
||||||
|
import { ResetPasswordValidationSchema } from '../_validation/reset-password-validation';
|
||||||
|
|
||||||
|
export function ResetPasswordForm() {
|
||||||
|
const form = useResetPasswordForm();
|
||||||
|
const { mutate, error, isPending } = usePostResetPassword(form);
|
||||||
|
|
||||||
|
const onSubmit = (values: ResetPasswordValidationSchema) => {
|
||||||
|
mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm w-full">
|
||||||
|
{(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 w-full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>New Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirm_password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Confirm New Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<LuLoaderCircle className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Ubah password'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { resetPasswordAction } from '../_actions/reset-password-actions';
|
||||||
|
import { useResetPasswordForm } from './use-reset-password-form';
|
||||||
|
|
||||||
|
export function usePostResetPassword(
|
||||||
|
form: ReturnType<typeof useResetPasswordForm>
|
||||||
|
) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: resetPasswordAction,
|
||||||
|
onSuccess: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
router.replace('/signin');
|
||||||
|
toast.success(message);
|
||||||
|
},
|
||||||
|
onError: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resetPasswordValidationSchema,
|
||||||
|
ResetPasswordValidationSchema,
|
||||||
|
} from '../_validation/reset-password-validation';
|
||||||
|
|
||||||
|
export function useResetPasswordForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tokenFromQuery = searchParams.get('token') ?? '';
|
||||||
|
|
||||||
|
return useForm<ResetPasswordValidationSchema>({
|
||||||
|
resolver: zodResolver(resetPasswordValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
token: tokenFromQuery,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const resetPasswordValidationSchema = z
|
||||||
|
.object({
|
||||||
|
password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Password tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Password harus berupa string',
|
||||||
|
})
|
||||||
|
.min(8, 'Password harus minimal 8 karakter')
|
||||||
|
.max(50, 'Password tidak boleh lebih dari 50 karakter')
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/,
|
||||||
|
'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial'
|
||||||
|
),
|
||||||
|
confirm_password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Konfirmasi password tidak boleh kosong',
|
||||||
|
})
|
||||||
|
.min(8, 'Konfirmasi password harus minimal 8 karakter')
|
||||||
|
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
|
||||||
|
token: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirm_password, {
|
||||||
|
message: 'Password dan Konfirmasi Password harus sama',
|
||||||
|
path: ['confirm_password'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResetPasswordValidationSchema = z.infer<
|
||||||
|
typeof resetPasswordValidationSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { use } from 'react';
|
||||||
|
import { ResetPasswordForm } from './_components/reset-password-form';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ token?: string }>;
|
||||||
|
}) {
|
||||||
|
const { token } = use(searchParams);
|
||||||
|
|
||||||
|
if (!token) redirect('/signin');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LogoSimple />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||||
|
Ubah passwordmu
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { setAccessToken, setRefreshToken } from '@/lib/cookies';
|
||||||
|
import { fetchPostSignin } from '../_http/fetch-post-signin';
|
||||||
|
import {
|
||||||
|
type SignInValidationType,
|
||||||
|
signInValidationSchema,
|
||||||
|
} from '../_validation/signin-validation';
|
||||||
|
|
||||||
|
export async function SigninAction(request: SignInValidationType) {
|
||||||
|
const validRequest = signInValidationSchema.parse(request);
|
||||||
|
|
||||||
|
const { data } = await fetchPostSignin(validRequest);
|
||||||
|
|
||||||
|
const accessToken = data.token.access_token;
|
||||||
|
const refreshToken = data.token.refresh_token;
|
||||||
|
|
||||||
|
await setAccessToken(accessToken);
|
||||||
|
await setRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { LuLoader } from 'react-icons/lu';
|
||||||
|
import { useFormSignin } from '../_hooks/use-form-signin';
|
||||||
|
import { usePostSignin } from '../_hooks/use-post-signin';
|
||||||
|
import { type SignInValidationType } from '../_validation/signin-validation';
|
||||||
|
|
||||||
|
export function SigninForm() {
|
||||||
|
const form = useFormSignin();
|
||||||
|
const { mutate, isPending, error } = usePostSignin(form);
|
||||||
|
|
||||||
|
const onSubmit = (values: SignInValidationType) => {
|
||||||
|
mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
|
{(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<div className="text-right mt-1">
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-primary-500 font-bold hover:underline"
|
||||||
|
>
|
||||||
|
Lupa Password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full font-bold">
|
||||||
|
{isPending ? <LuLoader className="h-5 w-5 animate-spin" /> : 'Masuk'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
signInValidationSchema,
|
||||||
|
type SignInValidationType,
|
||||||
|
} from '../_validation/signin-validation';
|
||||||
|
|
||||||
|
export function useFormSignin() {
|
||||||
|
return useForm<SignInValidationType>({
|
||||||
|
resolver: zodResolver(signInValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { SigninAction } from '../_actions/signin-action';
|
||||||
|
import { useFormSignin } from './use-form-signin';
|
||||||
|
|
||||||
|
export function usePostSignin(form: ReturnType<typeof useFormSignin>) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: SigninAction,
|
||||||
|
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push('/');
|
||||||
|
},
|
||||||
|
onError: (error, variables) => {
|
||||||
|
form.resetField('password');
|
||||||
|
|
||||||
|
if (error.message.includes('not active')) {
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/verification?ref=${variables.email}`);
|
||||||
|
}, 750);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import { SignInValidationType } from '../_validation/signin-validation';
|
||||||
|
|
||||||
|
export async function fetchPostSignin({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
}: SignInValidationType) {
|
||||||
|
const { data, error, response } = await fetcher.POST('/v1/auth/login', {
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Someting went wrong, please try again later');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const signInValidationSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Email tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Email harus berupa string',
|
||||||
|
})
|
||||||
|
.min(1, 'Email tidak boleh kosong')
|
||||||
|
.email('Email harus valid'),
|
||||||
|
password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Password tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Password harus berupa string',
|
||||||
|
})
|
||||||
|
.min(1, 'Password tidak boleh kosong'),
|
||||||
|
});
|
||||||
|
export type SignInValidationType = z.infer<typeof signInValidationSchema>;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { SigninForm } from './_components/signin-form';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'IMPHNEN - Signin',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LogoSimple />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||||
|
Masuk untuk mengakses akunmu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigninForm />
|
||||||
|
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<p className="text-muted-foreground px-4 text-center text-xs leading-5 text-balance sm:text-sm">
|
||||||
|
Belum punya akun?{' '}
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="font-bold hover:underline hover:underline-offset-4 transition-colors text-primary-500"
|
||||||
|
>
|
||||||
|
Daftar
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
|
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
||||||
|
import { signupValidationSchema } from '../_validation/signup-validation';
|
||||||
|
|
||||||
|
export async function SignupAction(
|
||||||
|
request: z.infer<typeof signupValidationSchema>
|
||||||
|
) {
|
||||||
|
const validRequest = signupValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
|
validRequest.token,
|
||||||
|
remoteIp
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
|
const data = await fetchPostSignin(validRequest);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@components';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { LuLoader } from 'react-icons/lu';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { SignupAction } from '../_actions/signup-action';
|
||||||
|
import {
|
||||||
|
signupValidationSchema,
|
||||||
|
stepOneSignupValidationSchema,
|
||||||
|
stepTwoSignupValidationSchema,
|
||||||
|
} from '../_validation/signup-validation';
|
||||||
|
|
||||||
|
export function SignupForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const ref = useRef<TurnstileInstance | null>(null);
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [stepOneData, setStepOneData] = useState<z.infer<
|
||||||
|
typeof stepOneSignupValidationSchema
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
const firstForm = useForm<z.infer<typeof stepOneSignupValidationSchema>>({
|
||||||
|
resolver: zodResolver(stepOneSignupValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
phone_number: '',
|
||||||
|
fullname: '',
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondForm = useForm<z.infer<typeof stepTwoSignupValidationSchema>>({
|
||||||
|
resolver: zodResolver(stepTwoSignupValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate, isPending, error } = useMutation({
|
||||||
|
mutationFn: async (data: z.infer<typeof signupValidationSchema>) => {
|
||||||
|
const result = await SignupAction(data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
email: data.email,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: ({ email }) => {
|
||||||
|
router.push(`/verification?ref=${email}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
ref.current?.reset();
|
||||||
|
secondForm.resetField('token');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFirstSubmit = (
|
||||||
|
values: z.infer<typeof stepOneSignupValidationSchema>
|
||||||
|
) => {
|
||||||
|
setStepOneData(values);
|
||||||
|
setStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondSubmit = (
|
||||||
|
values: z.infer<typeof stepTwoSignupValidationSchema>
|
||||||
|
) => {
|
||||||
|
if (stepOneData) {
|
||||||
|
mutate({ ...stepOneData, ...values });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{step === 1 && (
|
||||||
|
<Form {...firstForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={firstForm.handleSubmit(handleFirstSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={firstForm.control}
|
||||||
|
name="fullname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Full Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Nama Lengkap" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={firstForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={firstForm.control}
|
||||||
|
name="phone_number"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nomor Telepon</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="08123456789" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={firstForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={firstForm.control}
|
||||||
|
name="confirm_password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Confirm Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#5fbaef] hover:bg-[#22a5f1]"
|
||||||
|
>
|
||||||
|
Selanjutnya
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Form {...secondForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={secondForm.handleSubmit(handleSecondSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
|
{(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
ref={ref}
|
||||||
|
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||||
|
onSuccess={(token) => secondForm.setValue('token', token)}
|
||||||
|
options={{
|
||||||
|
theme: 'light',
|
||||||
|
size: 'flexible',
|
||||||
|
language: 'id',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || !secondForm.watch('token')}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<LuLoader className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Daftar'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import { SignupValidationSchema } from '../_validation/signup-validation';
|
||||||
|
|
||||||
|
export async function fetchPostSignin({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
fullname,
|
||||||
|
phone_number,
|
||||||
|
}: SignupValidationSchema) {
|
||||||
|
const { data, error, response } = await fetcher.POST('/v1/auth/register', {
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
fullname,
|
||||||
|
phone_number,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Someting went wrong, please try again later');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const stepOneSignupValidationSchema = z
|
||||||
|
.object({
|
||||||
|
email: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Email tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Email harus berupa string',
|
||||||
|
})
|
||||||
|
.min(1, 'Email tidak boleh kosong')
|
||||||
|
.email('Email harus valid'),
|
||||||
|
fullname: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Nama tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Nama harus berupa string',
|
||||||
|
})
|
||||||
|
.min(1, 'Nama tidak boleh kosong')
|
||||||
|
.max(50, 'Nama tidak boleh lebih dari 50 karakter'),
|
||||||
|
password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Password tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Password harus berupa string',
|
||||||
|
})
|
||||||
|
.min(8, 'Password harus minimal 8 karakter')
|
||||||
|
.max(50, 'Password tidak boleh lebih dari 50 karakter')
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/,
|
||||||
|
'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial'
|
||||||
|
),
|
||||||
|
confirm_password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Konfirmasi password tidak boleh kosong',
|
||||||
|
})
|
||||||
|
.min(8, 'Konfirmasi password harus minimal 8 karakter')
|
||||||
|
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
|
||||||
|
phone_number: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Nomor telepon tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Nomor telepon harus berupa string',
|
||||||
|
})
|
||||||
|
.min(10, 'Nomor telepon tidak boleh kurang dari 10 karakter')
|
||||||
|
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter')
|
||||||
|
.regex(/^\d+$/, 'Nomor telepon hanya boleh berisi angka'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirm_password, {
|
||||||
|
message: 'Password dan Konfirmasi Password harus sama',
|
||||||
|
path: ['confirm_password'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stepTwoSignupValidationSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const signupValidationSchema = stepOneSignupValidationSchema.and(
|
||||||
|
stepTwoSignupValidationSchema
|
||||||
|
);
|
||||||
|
export type SignupValidationSchema = z.infer<typeof signupValidationSchema>;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { SignupForm } from './_components/signup-form';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'IMPHNEN - Signup',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LogoSimple />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||||
|
Buat akunmu sekarang
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignupForm />
|
||||||
|
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<p className="text-muted-foreground px-4 text-center text-xs leading-5 text-balance sm:text-sm">
|
||||||
|
Sudah punya akun?{' '}
|
||||||
|
<Link
|
||||||
|
href="/signin"
|
||||||
|
className="font-bold hover:underline hover:underline-offset-4 transition-colors text-primary-500"
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
|
import {
|
||||||
|
resendOTPValidationSchema,
|
||||||
|
type ResendOTPValidationType,
|
||||||
|
} from '../_validation/resend-otp-validation';
|
||||||
|
|
||||||
|
export async function resendOTPAction(request: ResendOTPValidationType) {
|
||||||
|
const validRequest = resendOTPValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
|
validRequest.token,
|
||||||
|
remoteIp
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
|
const { data, error } = await fetcher.POST('/v1/auth/send-otp', {
|
||||||
|
body: {
|
||||||
|
email: validRequest.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { fetchPostVerifyEmail } from '../_http/fetch-post-verify-email';
|
||||||
|
import {
|
||||||
|
type VerifyEmailValidationType,
|
||||||
|
verifyEmailValidationSchema,
|
||||||
|
} from '../_validation/verify-email-validation';
|
||||||
|
|
||||||
|
export async function verifyEmailAction(request: VerifyEmailValidationType) {
|
||||||
|
const validRequest = verifyEmailValidationSchema.parse(request);
|
||||||
|
|
||||||
|
const data = await fetchPostVerifyEmail(validRequest);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@components';
|
||||||
|
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useFormResendOTP } from '../_hooks/use-form-resend-otp';
|
||||||
|
import { usePostResendOTP } from '../_hooks/use-post-resend-otp';
|
||||||
|
import { ResendOTPValidationType } from '../_validation/resend-otp-validation';
|
||||||
|
|
||||||
|
export function ResendOTPForm() {
|
||||||
|
const ref = useRef<TurnstileInstance | null>(null);
|
||||||
|
|
||||||
|
const form = useFormResendOTP();
|
||||||
|
|
||||||
|
const { mutate, isPending, error } = usePostResendOTP(form);
|
||||||
|
|
||||||
|
const onSubmit = (values: ResendOTPValidationType) => {
|
||||||
|
mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
|
{(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
ref={ref}
|
||||||
|
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||||
|
onSuccess={(token) => form.setValue('token', token)}
|
||||||
|
options={{
|
||||||
|
theme: 'light',
|
||||||
|
size: 'flexible',
|
||||||
|
language: 'id',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isPending || !form.watch('token')}
|
||||||
|
>
|
||||||
|
Kirim Ulang Kode OTP
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ResendOTPForm } from './resend-otp-form';
|
||||||
|
import { VerifyEmailForm } from './verify-email-form';
|
||||||
|
|
||||||
|
export function VerificationTabs() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'form' | 'resend'>('form');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex space-x-2 border-b">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 w-full',
|
||||||
|
activeTab === 'form'
|
||||||
|
? 'border-b-2 border-primary-500 font-semibold'
|
||||||
|
: 'text-gray-500'
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('form')}
|
||||||
|
>
|
||||||
|
Verifikasi Email
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 w-full',
|
||||||
|
activeTab === 'resend'
|
||||||
|
? 'border-b-2 border-primary-500 font-semibold'
|
||||||
|
: 'text-gray-500'
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('resend')}
|
||||||
|
>
|
||||||
|
Resend OTP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'form' ? <VerifyEmailForm /> : <ResendOTPForm />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@components';
|
||||||
|
import { LuLoader } from 'react-icons/lu';
|
||||||
|
import { useFormVerifyEmail } from '../_hooks/use-form-verify-email';
|
||||||
|
import { usePostVerifyEmail } from '../_hooks/use-post-verify-email';
|
||||||
|
import { type VerifyEmailValidationType } from '../_validation/verify-email-validation';
|
||||||
|
|
||||||
|
export function VerifyEmailForm() {
|
||||||
|
const form = useFormVerifyEmail();
|
||||||
|
const { mutate, isPending, error } = usePostVerifyEmail(form);
|
||||||
|
|
||||||
|
const onSubmit = (values: VerifyEmailValidationType) => {
|
||||||
|
mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
|
{(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="otp"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>OTP</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="123456" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<LuLoader className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Verifikasi'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
resendOTPValidationSchema,
|
||||||
|
type ResendOTPValidationType,
|
||||||
|
} from '../_validation/resend-otp-validation';
|
||||||
|
|
||||||
|
export function useFormResendOTP() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const email = searchParams.get('ref');
|
||||||
|
|
||||||
|
return useForm<ResendOTPValidationType>({
|
||||||
|
resolver: zodResolver(resendOTPValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: email ?? '',
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
verifyEmailValidationSchema,
|
||||||
|
type VerifyEmailValidationType,
|
||||||
|
} from '../_validation/verify-email-validation';
|
||||||
|
|
||||||
|
export function useFormVerifyEmail() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const email = searchParams.get('ref');
|
||||||
|
|
||||||
|
return useForm<VerifyEmailValidationType>({
|
||||||
|
resolver: zodResolver(verifyEmailValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: email ?? '',
|
||||||
|
otp: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { resendOTPAction } from '../_actions/resend-otp-action';
|
||||||
|
import { useFormResendOTP } from './use-form-resend-otp';
|
||||||
|
|
||||||
|
export function usePostResendOTP(form: ReturnType<typeof useFormResendOTP>) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: resendOTPAction,
|
||||||
|
onSuccess: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.success(message);
|
||||||
|
},
|
||||||
|
onError: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { verifyEmailAction } from '../_actions/verify-email-action';
|
||||||
|
import { useFormVerifyEmail } from './use-form-verify-email';
|
||||||
|
|
||||||
|
export function usePostVerifyEmail(
|
||||||
|
form: ReturnType<typeof useFormVerifyEmail>
|
||||||
|
) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: verifyEmailAction,
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push('/');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
form.resetField('otp');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import { VerifyEmailValidationType } from '../_validation/verify-email-validation';
|
||||||
|
|
||||||
|
export async function fetchPostVerifyEmail({
|
||||||
|
email,
|
||||||
|
otp,
|
||||||
|
}: VerifyEmailValidationType) {
|
||||||
|
const formattedOTP = parseInt(otp);
|
||||||
|
|
||||||
|
const { data, error, response } = await fetcher.POST(
|
||||||
|
'/v1/auth/verify-email',
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
otp: formattedOTP,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Something went wrong, please try again later');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const resendOTPValidationSchema = z.object({
|
||||||
|
email: z.string().email({ message: 'Format email tidak valid' }),
|
||||||
|
token: z.string().min(1, { message: 'OTP harus terdiri dari 1 digit' }),
|
||||||
|
});
|
||||||
|
export type ResendOTPValidationType = z.infer<typeof resendOTPValidationSchema>;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const verifyEmailValidationSchema = z.object({
|
||||||
|
email: z.string().email({ message: 'Format email tidak valid' }),
|
||||||
|
otp: z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: 'OTP harus terdiri dari 6 digit' })
|
||||||
|
.max(6, { message: 'OTP harus terdiri dari 6 digit' }),
|
||||||
|
});
|
||||||
|
export type VerifyEmailValidationType = z.infer<
|
||||||
|
typeof verifyEmailValidationSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
import { LogoSimple } from '../../_components/logo';
|
||||||
|
import { VerificationTabs } from './_components/verification-tabs';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LogoSimple />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||||
|
Verifikasi akun mu sekarang
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<VerificationTabs />
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@components';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function CallToAction() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="w-full py-20 md:py-32 relative overflow-hidden">
|
|
||||||
{/* Background Elements */}
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background to-primary/20" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.2),transparent_70%)]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container px-4 md:px-6" ref={ref}>
|
|
||||||
<motion.div
|
|
||||||
className="max-w-4xl mx-auto rounded-2xl overflow-hidden border shadow-lg"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<div className="relative p-8 md:p-12 lg:p-16 bg-background">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5" />
|
|
||||||
|
|
||||||
<div className="relative z-10 text-center">
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6">
|
|
||||||
Siap Menjadi{' '}
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
Programmer Handal?
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
|
|
||||||
Bergabunglah dengan komunitas IMPHNEN sekarang dan mulai
|
|
||||||
perjalanan programming mu dengan cara yang menyenangkan!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
window.open('https://discord.gg/imphnen', '_blank')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gabung Discord
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="border-primary hover:bg-primary/10"
|
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
'https://facebook.com/groups/programmerhandal',
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Join Facebook Group
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Decorative Elements */}
|
|
||||||
<div className="absolute -top-12 -left-12 w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
|
|
||||||
<div className="absolute -bottom-12 -right-12 w-24 h-24 rounded-full bg-blue-400/10 blur-2xl" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import COMMUNITIES_STATS from '@/data/communities-stats.json';
|
|
||||||
import COMMUNITIES from '@/data/communities.json';
|
|
||||||
import { Button } from '@components';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function Community() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 0.5 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="komunitas"
|
|
||||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.1),transparent_70%)]" />
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(59,130,246,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(59,130,246,0.01)_1px,transparent_1px)] bg-[size:14px_14px]" />
|
|
||||||
</div>
|
|
||||||
<div className="container px-4 md:px-6" ref={ref}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl text-center mb-4">
|
|
||||||
Komunitas{' '}
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
Kami
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground text-center md:text-lg">
|
|
||||||
Bergabunglah dengan ribuan programmer Indonesia yang saling membantu
|
|
||||||
dan berbagi pengalaman.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-3 mt-12"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{COMMUNITIES.map((c, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
|
||||||
<Icon
|
|
||||||
icon={c.iconName}
|
|
||||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold">{c.title}</h3>
|
|
||||||
<p className="mb-6 text-muted-foreground">{c.description}</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300"
|
|
||||||
onClick={() => window.open(c.buttonLink, '_blank')}
|
|
||||||
>
|
|
||||||
{c.buttonText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-1 -right-1 w-20 h-20 bg-gradient-to-tl from-primary/20 to-transparent rounded-tl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
{COMMUNITIES_STATS.map((s, i) => (
|
|
||||||
<div key={i} className="space-y-2">
|
|
||||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
{s.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import FEATURES from '@/data/features.json';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function Features() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 0.5 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="fitur"
|
|
||||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-muted/50" />
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-gradient-to-tr from-primary/5 to-blue-400/5 blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container px-4 md:px-6" ref={ref}>
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
|
||||||
<motion.div
|
|
||||||
className="space-y-2"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary">
|
|
||||||
Fitur Unggulan
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
|
||||||
Belajar programming dengan cara yang lebih baik
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
|
||||||
IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi
|
|
||||||
programmer handal tanpa harus pusing dengan coding.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{FEATURES.map((feature, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background/50 backdrop-blur-sm p-6 transition-all hover:shadow-md hover:shadow-primary/5 hover:border-primary/50"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
|
||||||
<Icon
|
|
||||||
icon={feature.iconName}
|
|
||||||
className="h-6 w-6 text-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold">{feature.title}</h3>
|
|
||||||
<p className="text-muted-foreground">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 h-1 w-0 bg-gradient-to-r from-primary to-blue-400 group-hover:w-full transition-all duration-300" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import LEARNING_RESOURCES from '@/data/learning-resources.json';
|
|
||||||
import { Button } from '@components';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function LearningResources() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 0.5 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="sumber-belajar"
|
|
||||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-background" />
|
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-1/2 bg-gradient-to-bl from-primary/5 to-transparent blur-3xl" />
|
|
||||||
<div className="absolute bottom-0 left-0 w-1/2 h-1/2 bg-gradient-to-tr from-blue-400/5 to-transparent blur-3xl" />
|
|
||||||
</div>
|
|
||||||
<div className="container px-4 md:px-6" ref={ref}>
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
|
||||||
Sumber Belajar
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
|
||||||
Akses berbagai materi belajar yang akan membantu kamu menguasai
|
|
||||||
konsep programming dengan cara yang menyenangkan.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{LEARNING_RESOURCES.map((r, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 hover:shadow-lg"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 h-1 w-full bg-gradient-to-r from-primary/50 to-blue-400/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
|
||||||
<Icon
|
|
||||||
icon={r.icon}
|
|
||||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold">{r.title}</h3>
|
|
||||||
<p className="mb-6 text-muted-foreground">{r.description}</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="p-0 h-auto font-medium text-primary hover:text-primary/80"
|
|
||||||
onClick={() => window.open(r.buttonLink, '_blank')}
|
|
||||||
>
|
|
||||||
{r.buttonText}
|
|
||||||
<Icon icon="tabler:arrow-right" className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-32 -right-32 w-64 h-64 bg-gradient-to-tl from-primary/10 to-transparent rounded-full opacity-0 group-hover:opacity-100 transition-all duration-500 group-hover:-translate-y-10 group-hover:-translate-x-10" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<div className="grid md:grid-cols-2 gap-0">
|
|
||||||
<div className="p-8 md:p-12 flex flex-col justify-center">
|
|
||||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary mb-4 w-fit">
|
|
||||||
Rekomendasi Terbaik
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
|
||||||
Kursus Lengkap Web Development
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
Pelajari HTML, CSS, JavaScript, React, dan Node.js dalam satu
|
|
||||||
kursus komprehensif yang dirancang untuk pemula hingga tingkat
|
|
||||||
menengah.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<Button
|
|
||||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
|
||||||
onClick={() => window.open('/', '_blank')}
|
|
||||||
>
|
|
||||||
Mulai Kursus
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => window.open('/', '_blank')}
|
|
||||||
>
|
|
||||||
Lihat Silabus
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-64 md:h-auto">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-blue-400/20" />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-background/80 backdrop-blur-sm flex items-center justify-center cursor-pointer hover:bg-background transition-colors">
|
|
||||||
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-primary border-b-8 border-b-transparent ml-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import TESTIMONIAL_STATS from '@/data/testimonial-stats.json';
|
|
||||||
import TESTIMONIALS from '@/data/testimonials.json';
|
|
||||||
import { QuoteIcon } from '@components';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function Testimonials() {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="testimoni"
|
|
||||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container px-4 md:px-6">
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl lg:text-5xl">
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
Testimoni
|
|
||||||
</span>
|
|
||||||
<span> Member</span>
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
|
||||||
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-3"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{TESTIMONIALS.map((t, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={idx}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
|
|
||||||
<QuoteIcon className="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<p className="mb-6 text-muted-foreground italic">
|
|
||||||
“{t.quote}”
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
|
|
||||||
<Image
|
|
||||||
src={t.avatar}
|
|
||||||
alt={t.name}
|
|
||||||
width={600}
|
|
||||||
height={500}
|
|
||||||
className="object-cover"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-bold">{t.name}</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">{t.role}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<div className="grid md:grid-cols-2 gap-8 items-center">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
|
||||||
Bergabunglah dengan 10,000+ programmer Indonesia lainnya
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Komunitas kami terus berkembang dengan programmer dari berbagai
|
|
||||||
latar belakang dan tingkat keahlian. Bersama-sama, kita belajar,
|
|
||||||
berbagi, dan tumbuh sebagai profesional.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{TESTIMONIAL_STATS.map((s, idx) => (
|
|
||||||
<div key={idx} className="rounded-lg border p-4 text-center">
|
|
||||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
{s.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { CallToAction } from './_components/call-to-action';
|
|
||||||
import { Community } from './_components/community';
|
|
||||||
import { Features } from './_components/features';
|
|
||||||
import { Hero } from './_components/hero';
|
|
||||||
import { LearningResources } from './_components/learning-resources';
|
|
||||||
import { Testimonials } from './_components/testimonials';
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Hero />
|
|
||||||
<Features />
|
|
||||||
<Community />
|
|
||||||
<LearningResources />
|
|
||||||
<Testimonials />
|
|
||||||
<CallToAction />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import SOCIALS from '@/data/socials.json';
|
||||||
|
import { motion, useInView } from 'framer-motion';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import {
|
||||||
|
FaArrowRight,
|
||||||
|
FaDiscord,
|
||||||
|
FaFacebook,
|
||||||
|
FaInstagram,
|
||||||
|
FaLinkedin,
|
||||||
|
FaTiktok,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
export function CommunitySection() {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 20, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconComponent = (iconName: string) => {
|
||||||
|
switch (iconName) {
|
||||||
|
case 'FaFacebook':
|
||||||
|
return FaFacebook;
|
||||||
|
case 'FaDiscord':
|
||||||
|
return FaDiscord;
|
||||||
|
case 'FaInstagram':
|
||||||
|
return FaInstagram;
|
||||||
|
case 'FaTiktok':
|
||||||
|
return FaTiktok;
|
||||||
|
case 'FaLinkedin':
|
||||||
|
return FaLinkedin;
|
||||||
|
default:
|
||||||
|
return FaArrowRight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformColors = (iconName: string) => {
|
||||||
|
switch (iconName) {
|
||||||
|
case 'FaFacebook':
|
||||||
|
return {
|
||||||
|
iconColor: 'text-[#1877F2]',
|
||||||
|
};
|
||||||
|
case 'FaDiscord':
|
||||||
|
return {
|
||||||
|
iconColor: 'text-[#5865F2]',
|
||||||
|
};
|
||||||
|
case 'FaInstagram':
|
||||||
|
return {
|
||||||
|
iconColor: 'text-[#E4405F]',
|
||||||
|
};
|
||||||
|
case 'FaTiktok':
|
||||||
|
return {
|
||||||
|
iconColor: 'text-[#000000]',
|
||||||
|
};
|
||||||
|
case 'FaLinkedin':
|
||||||
|
return {
|
||||||
|
iconColor: 'text-[#0A66C2]',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
iconColor: 'text-primary-500',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="community" className="w-full py-20 md:py-28">
|
||||||
|
<div className="container" ref={ref}>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
|
||||||
|
Bergabung dengan Komunitas Kami di
|
||||||
|
<span className="block mt-2 text-primary-500">
|
||||||
|
Berbagai Platform
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
|
||||||
|
Terhubung dengan sesama developer di komunitas kami
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? 'visible' : 'hidden'}
|
||||||
|
>
|
||||||
|
{SOCIALS.map((community, index) => {
|
||||||
|
const IconComponent = getIconComponent(community.icon);
|
||||||
|
const colors = getPlatformColors(community.icon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
variants={itemVariants}
|
||||||
|
className="group relative p-8 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start gap-5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<IconComponent
|
||||||
|
className={`w-8 h-8 ${colors.iconColor} transition-colors`}
|
||||||
|
/>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
|
{community.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm/relaxed">
|
||||||
|
{community.description}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={community.link}
|
||||||
|
className={`inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span>Jelajahi Komunitas</span>
|
||||||
|
<FaArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import { motion, useInView } from 'framer-motion';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { FaArrowRight } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export function CTASection() {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
|
||||||
|
{/* Background pattern */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-10"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div className="container relative" ref={ref}>
|
||||||
|
<div className="flex flex-col lg:flex-row items-center justify-between gap-12">
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 text-center lg:text-left space-y-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-4xl md:text-5xl font-bold text-white leading-tight">
|
||||||
|
<span className="block">LET'S GO</span>
|
||||||
|
<span className="block text-5xl md:text-6xl mt-2">
|
||||||
|
<span className="text-yellow-300">SAAT</span>
|
||||||
|
<span className="text-white">NYA</span>
|
||||||
|
</span>
|
||||||
|
<span className="block mt-2">KAMU JOIN!</span>
|
||||||
|
</h2>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={isInView ? { opacity: 1 } : {}}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="text-lg md:text-xl text-white/90 max-w-2xl mx-auto lg:mx-0"
|
||||||
|
>
|
||||||
|
Jadilah bagian dari komunitas developer terbesar di Indonesia.
|
||||||
|
Tingkatkan skill, perluas jaringan, dan raih kesempatan karir
|
||||||
|
bersama kami!
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={isInView ? { scale: 1, opacity: 1 } : {}}
|
||||||
|
transition={{ type: 'spring', stiffness: 100 }}
|
||||||
|
className="flex justify-center lg:justify-start"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#community"
|
||||||
|
className="flex items-center gap-4 px-8 py-4 bg-yellow-300 hover:bg-yellow-400 text-gray-900 rounded-full text-lg font-semibold transition-all hover:gap-6 group"
|
||||||
|
>
|
||||||
|
<span>Join Sekarang</span>
|
||||||
|
<FaArrowRight className="w-5 h-5 transition-all group-hover:rotate-45" />
|
||||||
|
</a>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Illustration */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="flex-1 max-w-xl"
|
||||||
|
>
|
||||||
|
<div className="relative p-8">
|
||||||
|
<div className="absolute inset-0 bg-white/10 rounded-3xl transform rotate-6"></div>
|
||||||
|
<div className="relative bg-white/5 rounded-3xl p-8 backdrop-blur-lg border border-white/10">
|
||||||
|
<div className="flex flex-col items-center gap-6 text-white">
|
||||||
|
<LogoSimple className="w-[240px]" />
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-2xl font-bold">180.000+</h3>
|
||||||
|
<p className="text-lg">Programmer Sudah Bergabung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import HERO_CONTENT from '@/data/hero-content.json';
|
||||||
import HERO_STATS from '@/data/hero-stats.json';
|
import HERO_STATS from '@/data/hero-stats.json';
|
||||||
import { Button, SparklesIcon } from '@components';
|
import { Button } from '@components';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Fragment, useEffect, useState } from 'react';
|
import { Fragment, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export function Hero() {
|
export function HeroSection() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { communityLabel, headingLine1, headingLine2, description, buttons } =
|
||||||
|
HERO_CONTENT;
|
||||||
|
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -20,6 +26,7 @@ export function Hero() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
||||||
|
{/* Background gradients and motion */}
|
||||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
||||||
<div
|
<div
|
||||||
@@ -47,48 +54,34 @@ export function Hero() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<div className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
||||||
<SparklesIcon className="mr-1 h-3.5 w-3.5 text-primary" />
|
{communityLabel}
|
||||||
<span>Komunitas Programmer Indonesia</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
||||||
Programmer Handal, <br />
|
{headingLine1}{' '}
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
<span className="bg-clip-text bg-gradient-to-r text-primary-500">
|
||||||
Tanpa Ribet
|
{headingLine2}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[600px] text-muted-foreground md:text-xl">
|
<p className="max-w-[600px] text-muted-foreground md:text-xl">
|
||||||
Temukan potensi programming Anda bersama komunitas yang
|
{description}
|
||||||
mendukung, tutorial interaktif, dan sumber daya berkualitas
|
|
||||||
tinggi.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||||
<Button
|
<Button size="lg" onClick={() => router.push(buttons.joinUrl)}>
|
||||||
size="lg"
|
{buttons.join}
|
||||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
'https://facebook.com/groups/programmerhandal',
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Mulai Belajar
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="bordered"
|
||||||
className="w-full sm:w-auto group relative overflow-hidden border-primary"
|
className="w-full sm:w-auto group relative overflow-hidden"
|
||||||
onClick={() =>
|
onClick={() => router.push(buttons.exploreUrl)}
|
||||||
window.open('https://discord.com/invite/imphnen', '_blank')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
|
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
|
||||||
<span className="relative">Gabung Discord</span>
|
<span className="relative">{buttons.explore}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,7 +89,7 @@ export function Hero() {
|
|||||||
{HERO_STATS.map(({ value, label }, i) => (
|
{HERO_STATS.map(({ value, label }, i) => (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
<div className="text-2xl font-bold bg-clip-text text-primary-500">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import TESTIMONIALS from '@/data/testimonials.json';
|
||||||
|
import { buttonVariants } from '@components';
|
||||||
|
import { motion, useInView } from 'framer-motion';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { FaQuoteLeft } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export function TestimonialSection() {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.1 });
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 20, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full py-20 md:py-28 bg-gray-50">
|
||||||
|
<div className="container" ref={ref}>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
|
||||||
|
Apa Kata Mereka Tentang
|
||||||
|
<span className="block mt-2 text-primary-500">
|
||||||
|
Komunitas Kami?
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mb-16"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? 'visible' : 'hidden'}
|
||||||
|
>
|
||||||
|
{TESTIMONIALS.map((testimonial) => (
|
||||||
|
<motion.div
|
||||||
|
key={testimonial.id}
|
||||||
|
variants={itemVariants}
|
||||||
|
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Image
|
||||||
|
src={testimonial.image}
|
||||||
|
alt={testimonial.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-12 h-12 rounded-full object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-900">
|
||||||
|
{testimonial.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600">{testimonial.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 relative">
|
||||||
|
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
|
||||||
|
<p className="text-sm/relaxed">{testimonial.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Link href="/testimonials" className={buttonVariants({ size: 'lg' })}>
|
||||||
|
Tulis Testimonimu
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { CommunitySection } from './_components/community-section';
|
||||||
|
import { CTASection } from './_components/cta-section';
|
||||||
|
import { HeroSection } from './_components/hero-section';
|
||||||
|
import { TestimonialSection } from './_components/testimonial-section';
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroSection />
|
||||||
|
<CommunitySection />
|
||||||
|
<TestimonialSection />
|
||||||
|
<CTASection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import NAVIGATIONS from '@/data/navigations.json';
|
||||||
|
import SOCIALS from '@/data/socials.json';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
FaDiscord,
|
||||||
|
FaFacebook,
|
||||||
|
FaInstagram,
|
||||||
|
FaLinkedinIn,
|
||||||
|
FaTiktok,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="w-full border-t bg-background py-12 md:py-16">
|
||||||
|
<div className="container px-4 md:px-6">
|
||||||
|
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LogoSimple />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Ingin Menjadi Programmer Handal Namun Enggan Ngoding
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FaFacebook className="size-6" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FaDiscord className="size-6" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FaInstagram className="size-6" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FaTiktok className="size-6" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FaLinkedinIn className="size-6" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-bold">Halaman</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{NAVIGATIONS.map(({ link, title }) => (
|
||||||
|
<li key={link}>
|
||||||
|
<Link
|
||||||
|
href={link}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-bold">Link</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{SOCIALS.map(({ link, name }) => (
|
||||||
|
<li key={name}>
|
||||||
|
<Link
|
||||||
|
href={link}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-bold">Patners</h3>
|
||||||
|
<ul className="space-y-2"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 border-t pt-8 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
||||||
|
Handal, Namun Enggan Ngoding. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import NAVIGATIONS from '@/data/navigations.json';
|
||||||
|
import { Button } from '@components';
|
||||||
|
import { cn } from '@utils';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LuMenu, LuX } from 'react-icons/lu';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileMenuOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
};
|
||||||
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 w-full z-50 bg-background/70">
|
||||||
|
<div className="container flex h-20 items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="relative overflow-hidden rounded z-50"
|
||||||
|
aria-label="Home"
|
||||||
|
>
|
||||||
|
<LogoSimple className="w-24 md:w-28" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
|
{NAVIGATIONS.map(({ link, title }) => (
|
||||||
|
<Link
|
||||||
|
key={link}
|
||||||
|
href={link}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium relative group px-1 py-2',
|
||||||
|
pathname === link ? 'text-primary' : 'text-foreground/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="transition-colors hover:text-primary block">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{pathname === link && (
|
||||||
|
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary-500" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-x-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push('/signin')}
|
||||||
|
className="px-5 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onClick={() => router.push('/signup')}
|
||||||
|
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||||
|
>
|
||||||
|
Daftar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||||
|
className="flex md:hidden relative z-50 p-2 rounded-lg hover:bg-muted transition-colors"
|
||||||
|
aria-label={mobileMenuOpen ? 'Tutup menu' : 'Buka menu'}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<LuX className="size-6" />
|
||||||
|
) : (
|
||||||
|
<LuMenu className="size-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-60 bg-background flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="container flex h-20 items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="relative overflow-hidden rounded"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<LogoSimple className="w-24" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="p-2 rounded-lg hover:bg-muted transition-colors"
|
||||||
|
aria-label="Tutup menu"
|
||||||
|
>
|
||||||
|
<LuX className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.nav
|
||||||
|
className="flex-1 flex flex-col items-center justify-center gap-6 py-10"
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{NAVIGATIONS.map(({ link, title }) => (
|
||||||
|
<Link
|
||||||
|
key={link}
|
||||||
|
href={link}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-medium py-2 px-6 rounded-lg transition-colors',
|
||||||
|
pathname === link
|
||||||
|
? 'text-primary bg-primary/10'
|
||||||
|
: 'text-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</motion.nav>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="container space-y-4 pb-10"
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
router.push('/signin');
|
||||||
|
}}
|
||||||
|
className="w-full py-4 text-base"
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onClick={() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
router.push('/signup');
|
||||||
|
}}
|
||||||
|
className="w-full py-4 text-base shadow-lg shadow-primary/20"
|
||||||
|
>
|
||||||
|
Daftar
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button, MoonIcon, SunIcon } from '@components';
|
import { Button } from '@components';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LuMoon, LuSun } from 'react-icons/lu';
|
||||||
|
|
||||||
export function SimpleThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -22,15 +23,15 @@ export function SimpleThemeToggle() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="bordered"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="focus-visible:ring-0 cursor-pointer"
|
className="focus-visible:ring-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? (
|
{theme === 'dark' ? (
|
||||||
<SunIcon className="h-[1.2rem] w-[1.2rem]" />
|
<LuSun className="h-[1.2rem] w-[1.2rem]" />
|
||||||
) : (
|
) : (
|
||||||
<MoonIcon className="h-[1.2rem] w-[1.2rem]" />
|
<LuMoon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import events from '@/data/events.json';
|
||||||
|
import { buttonVariants } from '@components';
|
||||||
|
import { cn } from '@utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { HiCalendar, HiClock, HiLocationMarker } from 'react-icons/hi';
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('id-ID', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Asia/Jakarta',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventStatus = (endDate: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(endDate);
|
||||||
|
return end > now ? 'upcoming' : 'past';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EventsPage() {
|
||||||
|
const sortedEvents = [...events].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen bg-background container py-10">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Featured Card */}
|
||||||
|
<div className="col-span-full">
|
||||||
|
<div className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
|
||||||
|
<div className="grid md:grid-cols-2">
|
||||||
|
<div className="min-h-96 bg-muted relative">
|
||||||
|
<Image
|
||||||
|
src={sortedEvents[0].thumbnail}
|
||||||
|
alt={sortedEvents[0].name}
|
||||||
|
fill
|
||||||
|
className="object-cover object-top"
|
||||||
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
|
||||||
|
Event Terbaru
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 rounded-full text-xs',
|
||||||
|
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
|
||||||
|
? 'bg-primary/20 text-primary'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getEventStatus(sortedEvents[0].end_date) === 'upcoming'
|
||||||
|
? 'Upcoming'
|
||||||
|
: 'Selesai'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-foreground">
|
||||||
|
{sortedEvents[0].name}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3 mb-6 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HiCalendar className="w-4 h-4" />
|
||||||
|
<span>{formatDate(sortedEvents[0].start_date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HiClock className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{formatTime(sortedEvents[0].start_date)} -{' '}
|
||||||
|
{formatTime(sortedEvents[0].end_date)} WIB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HiLocationMarker className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{sortedEvents[0].type === 'online'
|
||||||
|
? 'Online'
|
||||||
|
: sortedEvents[0].location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sortedEvents[0].price > 0 && (
|
||||||
|
<div className="mt-1 font-medium">
|
||||||
|
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mb-6 line-clamp-4">
|
||||||
|
{sortedEvents[0].description}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={sortedEvents[0].detail_link}
|
||||||
|
target="_blank"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'bordered' }),
|
||||||
|
'mt-auto w-full md:w-fit'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Lihat Detail
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Regular Cards */}
|
||||||
|
{sortedEvents.slice(1).map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.name}
|
||||||
|
className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card"
|
||||||
|
>
|
||||||
|
<div className="h-48 bg-muted relative">
|
||||||
|
<Image
|
||||||
|
src={event.thumbnail}
|
||||||
|
alt={event.name}
|
||||||
|
fill
|
||||||
|
className="object-cover object-top rounded-t-xl"
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 rounded-full text-xs',
|
||||||
|
getEventStatus(event.end_date) === 'upcoming'
|
||||||
|
? 'bg-primary/20 text-primary'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getEventStatus(event.end_date) === 'upcoming'
|
||||||
|
? 'Upcoming'
|
||||||
|
: 'Selesai'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-foreground">
|
||||||
|
{event.name}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 mb-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HiCalendar className="w-4 h-4" />
|
||||||
|
<span>{formatDate(event.start_date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HiLocationMarker className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{event.type === 'online' ? 'Online' : event.location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{event.price > 0 && (
|
||||||
|
<div className="font-medium">
|
||||||
|
Rp {event.price.toLocaleString('id-ID')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={event.detail_link}
|
||||||
|
target="_blank"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'bordered' }),
|
||||||
|
'w-full text-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Lihat Detail
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Footer from './_components/footer';
|
||||||
|
import { Header } from './_components/header';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { RequestFeaturePopup } from './request-feature-popup';
|
||||||
|
|
||||||
|
export function FeatureRequestCTA() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="bg-primary-500 rounded-xl px-4 py-8 md:p-8 mb-8 text-center mx-auto max-w-7xl"
|
||||||
|
>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<motion.h1
|
||||||
|
className="text-2xl md:text-3xl font-bold text-white mb-3"
|
||||||
|
initial={{ scale: 0.95 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ delay: 0.2, type: 'spring' }}
|
||||||
|
>
|
||||||
|
Punya Ide Bagus untuk IMPHNEN?
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
className="text-white/90 mb-6 text-lg text-balance"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
>
|
||||||
|
Bagikan ide fitur yang kamu inginkan dan vote untuk membuatnya nyata!
|
||||||
|
</motion.p>
|
||||||
|
<RequestFeaturePopup />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button, Card, CardContent, CardHeader, CardTitle } from '@components';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { BiUpvote } from 'react-icons/bi';
|
||||||
|
import { FiCheckCircle } from 'react-icons/fi';
|
||||||
|
import { MdOutlineOpenInNew } from 'react-icons/md';
|
||||||
|
|
||||||
|
export default function ProjectsVote() {
|
||||||
|
const [upcomingItems, setUpcomingItems] = useState([
|
||||||
|
{
|
||||||
|
title: 'IMPHNEN Project Showcase',
|
||||||
|
description: 'Showcase projectmu ke member lain dan dapatkan feedback',
|
||||||
|
votes: 42,
|
||||||
|
voted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMPHNEN Meme Generator',
|
||||||
|
description: 'Bikin meme kocak kapanpun dengan mudah',
|
||||||
|
votes: 42,
|
||||||
|
voted: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inProgressItems = [
|
||||||
|
{
|
||||||
|
title: 'IMPHNEN Twibbon',
|
||||||
|
description: 'Buat Twibbon kece untuk profile media sosialmu',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMPHNEN Certificate',
|
||||||
|
description: 'Cetak sertifikat keren secara instan untuk anggota IMPHNEN',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const completedItems = [
|
||||||
|
{
|
||||||
|
title: 'IMPHNEN List Event',
|
||||||
|
description:
|
||||||
|
'Koleksi daftar event dan kolaborasi seru yang bisa kamu ikuti',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMPHNEN Testimoni',
|
||||||
|
description: 'Berikan testimonial gokil buat komunitas IMPHNEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMPHNEN Roadmap by Vote',
|
||||||
|
description: 'Usulkan ide fitur seru dan ajak anggota lain buat voting',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleVote = (index: number) => {
|
||||||
|
const newItems = [...upcomingItems];
|
||||||
|
newItems[index] = {
|
||||||
|
...newItems[index],
|
||||||
|
votes: newItems[index].voted
|
||||||
|
? newItems[index].votes - 1
|
||||||
|
: newItems[index].votes + 1,
|
||||||
|
voted: !newItems[index].voted,
|
||||||
|
};
|
||||||
|
setUpcomingItems(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 10 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.2 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-7xl mx-auto">
|
||||||
|
{/* Vote Now Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 bg-amber-100 text-amber-600 rounded-lg">
|
||||||
|
🔥
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Vote Now</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
{upcomingItems.map((item, index) => (
|
||||||
|
<motion.div key={index} variants={itemVariants} layout>
|
||||||
|
<Card className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
|
||||||
|
{item.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-grow">
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-100 pt-3">
|
||||||
|
<motion.button
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => handleVote(index)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
item.voted
|
||||||
|
? 'bg-primary-500 text-white hover:bg-primary-600'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
animate={{ scale: item.voted ? [1, 1.2, 1] : 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<BiUpvote
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
item.voted ? 'text-white' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</motion.span>
|
||||||
|
<span>{item.voted ? 'Voted' : 'Vote'}</span>
|
||||||
|
</motion.button>
|
||||||
|
<div className="flex items-center gap-2 bg-gray-50 px-3 py-1.5 rounded-lg">
|
||||||
|
<BiUpvote className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{item.votes}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In Progress Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 bg-primary-100 text-primary-600 rounded-lg">
|
||||||
|
🧑🔧
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">In Progress</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
{inProgressItems.map((item, index) => (
|
||||||
|
<motion.div key={index} variants={itemVariants} layout>
|
||||||
|
<Card className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
|
||||||
|
{item.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-600 text-sm">{item.description}</p>
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-primary-500 h-1.5 rounded-full"
|
||||||
|
style={{ width: `${(index + 1) * 33}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
{index === 0 ? 'Development started' : 'In development'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 bg-green-100 text-green-600 rounded-lg">
|
||||||
|
🤓
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Completed</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
{completedItems.map((item, index) => (
|
||||||
|
<motion.div key={index} variants={itemVariants} layout>
|
||||||
|
<Card className="bg-white border border-gray-200 hover:border-green-200 shadow-sm hover:shadow-md transition-all h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
|
||||||
|
{item.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-600 text-sm mb-3">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-green-600">
|
||||||
|
<FiCheckCircle className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Implemented</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="flex gap-x-2">
|
||||||
|
Coba sekarang
|
||||||
|
<MdOutlineOpenInNew className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Textarea,
|
||||||
|
} from '@components';
|
||||||
|
import { cn } from '@utils';
|
||||||
|
|
||||||
|
export function RequestFeaturePopup() {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="secondary">Request Fitur</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl">Request Fitur</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ProfileForm />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button variant="secondary">Request Fitur</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle>Request Fitur</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<ProfileForm className="px-4" />
|
||||||
|
<DrawerFooter className="pt-2">
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="bordered">Cancel</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileFormProps extends React.ComponentProps<'form'> {
|
||||||
|
showFooterButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileForm({
|
||||||
|
className,
|
||||||
|
showFooterButton = false,
|
||||||
|
}: ProfileFormProps) {
|
||||||
|
const [description, setDescription] = React.useState('');
|
||||||
|
const maxDescriptionLength = 10000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={cn('grid items-start gap-8', className)}>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Label htmlFor="namaFitur" className="font-medium">
|
||||||
|
Nama Fitur *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="namaFitur"
|
||||||
|
placeholder="Masukkan nama fitur yang diinginkan"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label htmlFor="deskripsiFitur" className="font-medium">
|
||||||
|
Deskripsi Fitur *
|
||||||
|
</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{description.length}/{maxDescriptionLength}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="deskripsiFitur"
|
||||||
|
placeholder="Jelaskan fitur yang Anda inginkan secara detail"
|
||||||
|
maxLength={maxDescriptionLength}
|
||||||
|
rows={6}
|
||||||
|
className="min-h-[150px]"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground -mt-2">
|
||||||
|
Jelaskan manfaat dan cara kerja fitur yang Anda inginkan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showFooterButton && (
|
||||||
|
<Button type="submit" size="lg" className="mt-2">
|
||||||
|
Kirim Permintaan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { FeatureRequestCTA } from './_components/feature-request-cta';
|
||||||
|
import ProjectsVote from './_components/projects-vote';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="pt-4 md:pt-6 pb-56 bg-gray-50 min-h-screen mx-4 lg:mx-0">
|
||||||
|
<FeatureRequestCTA />
|
||||||
|
<ProjectsVote />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import TESTIMONIALS from '@/data/testimonials.json';
|
||||||
|
import { Button } from '@components';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { BsChatLeftQuote } from 'react-icons/bs';
|
||||||
|
import { FaQuoteLeft } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="py-16 px-4 text-center border-b border-border">
|
||||||
|
<BsChatLeftQuote className="size-14 mx-auto my-4 text-foreground" />
|
||||||
|
<h1 className="text-4xl font-bold mb-4 text-foreground">Testimonial</h1>
|
||||||
|
<p className="max-w-2xl mx-auto text-lg mb-8 text-balance text-muted-foreground">
|
||||||
|
Menampilkan pengalaman dan cerita para member yang telah join ke dalam
|
||||||
|
komunitas kami
|
||||||
|
</p>
|
||||||
|
<Link href="/testimonials/submit">
|
||||||
|
<Button>Tulis Testimonialmu</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 my-16 container">
|
||||||
|
{TESTIMONIALS.map((testimonial) => (
|
||||||
|
<div
|
||||||
|
key={testimonial.id}
|
||||||
|
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Image
|
||||||
|
src={testimonial.image}
|
||||||
|
alt={testimonial.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-12 h-12 rounded-full object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-900">
|
||||||
|
{testimonial.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600">{testimonial.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 relative">
|
||||||
|
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
|
||||||
|
<p className="text-sm/relaxed">{testimonial.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||