Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2280bd6085 | ||
|
|
d39960f446 | ||
|
|
1c1c66fc0f | ||
|
|
60f0e98476 | ||
|
|
472e02e0b0 | ||
|
|
8d71173897 | ||
|
|
d2292a7fa0 | ||
|
|
b655de92c0 | ||
|
|
761eb0963a | ||
|
|
fc3c275fea | ||
|
|
3527458bc9 | ||
|
|
a7a7abddd2 | ||
|
|
06c71e8dcd | ||
|
|
50b9380add | ||
|
|
9bd326d7ad | ||
|
|
432a2d8d9e | ||
|
|
a9ec7e947f | ||
|
|
a867891001 | ||
|
|
6850313fe3 | ||
|
|
c3c203b555 | ||
|
|
341b4eef98 | ||
|
|
d03deb2d91 | ||
|
|
5f97352031 | ||
|
|
3b71a97d16 | ||
|
|
6e940d749a | ||
|
|
e971d33a05 | ||
|
|
0296a891eb | ||
|
|
47c8a25574 | ||
|
|
671a4f596b | ||
|
|
8c8e2c646f | ||
|
|
035f7fa8cd | ||
|
|
0a4921f2ef | ||
|
|
dbaaddab2e | ||
|
|
6c640f1cd4 | ||
|
|
e6f1b80cd3 | ||
|
|
5ca4caa0ba | ||
|
|
5e70f36a7e | ||
|
|
d009adcd70 | ||
|
|
92842c29bb | ||
|
|
684f299194 | ||
|
|
d5607b456b | ||
|
|
bb9c2f30a8 | ||
|
|
6819c24c6a | ||
|
|
f05fd5331d | ||
|
|
b1d9a56dda |
No files matched your search
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -1,9 +0,0 @@
|
|||||||
<svg width="317" height="170" viewBox="0 0 317 170" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<rect width="317" height="169.613" fill="url(#pattern0_164_317)"/>
|
|
||||||
<defs>
|
|
||||||
<pattern id="pattern0_164_317" patternContentUnits="objectBoundingBox" width="1" height="1">
|
|
||||||
<use xlink:href="#image0_164_317" transform="scale(0.00184502 0.00344828)"/>
|
|
||||||
</pattern>
|
|
||||||
<image id="image0_164_317" width="542" height="290" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAh4AAAEiCAYAAAC2k9sBAAAABGdBTUEAALGPC/xhBQAACklpQ0NQc1JHQiBJRUM2MTk2Ni0yLjEAAEiJnVN3WJP3Fj7f92UPVkLY8LGXbIEAIiOsCMgQWaIQkgBhhBASQMWFiApWFBURnEhVxILVCkidiOKgKLhnQYqIWotVXDjuH9yntX167+3t+9f7vOec5/zOec8PgBESJpHmomoAOVKFPDrYH49PSMTJvYACFUjgBCAQ5svCZwXFAADwA3l4fnSwP/wBr28AAgBw1S4kEsfh/4O6UCZXACCRAOAiEucLAZBSAMguVMgUAMgYALBTs2QKAJQAAGx5fEIiAKoNAOz0ST4FANipk9wXANiiHKLine truncated
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 351 KiB |
@@ -1,9 +0,0 @@
|
|||||||
<svg width="138" height="52" viewBox="0 0 138 52" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<rect width="137.505" height="51.7538" fill="url(#pattern0_164_316)"/>
|
|
||||||
<defs>
|
|
||||||
<pattern id="pattern0_164_316" patternContentUnits="objectBoundingBox" width="1" height="1">
|
|
||||||
<use xlink:href="#image0_164_316" transform="matrix(0.000440723 0 0 0.00117096 -0.173204 -1.3185)"/>
|
|
||||||
</pattern>
|
|
||||||
<image id="image0_164_316" width="3000" height="3000" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAC7gAAAu4CAYAAABxFfV5AAAACXBIWXMAAC4jAAAuIwF4pT92AAX6AElEQVR4nOzaMQEAIAzAsIF/z0MGPRIFFdCzuwMAAAAAAAAAAAAAAL/d3wEAAAAAAAAAAAAAADBjcAcAAAAAAAAAAAAAIMLgDgAAAAAAAAAAAABAgsEdAAAAAAAAAAAAAIAEgzsAAAAAAAAAAAAAAAkGdwAAAAAAAAAAAAAAEgzuAAAAAAAAAAAAAAAkGNwBAAAAAAAAAAAAAEgwuAMAAAAAAAAAAAAAkGBwBwAAAAAAAAAAAAAgweAOAAAAAAAAAAAAAECCwR0AAAAAAAAAAAAAgASDOwAAAAAAAAAAAAAACQZ3AAAALine truncated
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 511 KiB |
@@ -1,154 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './steam';
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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 Line truncated
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useSendOTP } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
export const useResendOtpHook = () => {
|
|
||||||
|
|
||||||
const { resendOTP, isLoading } = useSendOTP();
|
|
||||||
|
|
||||||
return {
|
|
||||||
resendOTP
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,38 +1,12 @@
|
|||||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
import { FC, ReactElement } 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]'>
|
||||||
@@ -52,13 +26,6 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
return <>Hallo</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -119,9 +119,3 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
@apply hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ const mappingPublicRoutes = [
|
|||||||
'/auth/login',
|
'/auth/login',
|
||||||
'/auth/forgot',
|
'/auth/forgot',
|
||||||
'/auth/forgot/otp',
|
'/auth/forgot/otp',
|
||||||
'/auth/register',
|
'/auth/register',
|
||||||
'/auth/register/otp',
|
'/auth/register/otp',
|
||||||
'/auth/register/success',
|
'/auth/register/success',
|
||||||
'/auth/new-password',
|
'/auth/new-password',
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
NEXT_PUBLIC_API_URL=
|
NEXT_PUBLIC_API_URL=
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITEKEY=1x00000000000000000000AA
|
||||||
TURNSTILE_SECRET_KEY=
|
|
||||||
NEXT_PUBLIC_TURNSTILE_SITEKEY=
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
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);
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { fetcher } from '@/lib/fetcher';
|
import { fetcher } from '@/lib/fetcher';
|
||||||
import { getRemoteIp } from '@/lib/headers';
|
|
||||||
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
|
||||||
import {
|
import {
|
||||||
forgotPasswordValidationSchema,
|
forgotPasswordValidationSchema,
|
||||||
ForgotPasswordValidationType,
|
ForgotPasswordValidationType,
|
||||||
@@ -12,14 +10,6 @@ export async function ForgotPasswordAction(
|
|||||||
request: ForgotPasswordValidationType
|
request: ForgotPasswordValidationType
|
||||||
) {
|
) {
|
||||||
const validRequest = forgotPasswordValidationSchema.parse(request);
|
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', {
|
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
|
||||||
body: {
|
body: {
|
||||||
@@ -27,6 +17,8 @@ export async function ForgotPasswordAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(data, error);
|
||||||
|
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@components';
|
} from '@components';
|
||||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef } from 'react';
|
||||||
import { LuLoader } from 'react-icons/lu';
|
import { LuLoader } from 'react-icons/lu';
|
||||||
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
||||||
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
||||||
@@ -20,74 +20,54 @@ import { ForgotPasswordValidationType } from '../_validation/forgot-password-val
|
|||||||
export function ForgotPasswordForm() {
|
export function ForgotPasswordForm() {
|
||||||
const ref = useRef<TurnstileInstance | null>(null);
|
const ref = useRef<TurnstileInstance | null>(null);
|
||||||
|
|
||||||
const [step, setStep] = useState<number>(1);
|
|
||||||
const [emailValue, setEmailValue] = useState<string>('');
|
|
||||||
|
|
||||||
const form = useFormForgotPassword();
|
const form = useFormForgotPassword();
|
||||||
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
||||||
|
|
||||||
const handleFirstStep = (values: ForgotPasswordValidationType) => {
|
const onSubmit = (values: ForgotPasswordValidationType) => {
|
||||||
setEmailValue(values.email);
|
mutate(values);
|
||||||
setStep(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSecondStep = () => {
|
|
||||||
mutate({ email: emailValue, token: form.getValues('token') });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-4">
|
||||||
onSubmit={
|
|
||||||
step === 1
|
|
||||||
? form.handleSubmit(handleFirstStep)
|
|
||||||
: (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSecondStep();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
className="w-full space-y-4"
|
|
||||||
>
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
{(error as Error).message}
|
{(error as Error).message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && (
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="email"
|
||||||
name="email"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
<Input placeholder="emailmu@mail.com" {...field} />
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
<Turnstile
|
||||||
<Turnstile
|
ref={ref}
|
||||||
ref={ref}
|
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||||
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
onSuccess={(token) => form.setValue('token', token)}
|
||||||
onSuccess={(token) => form.setValue('token', token)}
|
options={{
|
||||||
options={{ theme: 'light', size: 'flexible', language: 'id' }}
|
theme: 'light',
|
||||||
/>
|
size: 'flexible',
|
||||||
)}
|
language: 'id',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || (step === 2 && !form.watch('token'))}
|
disabled={isPending}
|
||||||
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LuLoader className="h-5 w-5 animate-spin" />
|
<LuLoader className="h-5 w-5 animate-spin" />
|
||||||
) : step === 1 ? (
|
|
||||||
'Selanjutnya'
|
|
||||||
) : (
|
) : (
|
||||||
'Reset password'
|
'Reset password'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ export function usePostForgotPassowrd(
|
|||||||
mutationFn: ForgotPasswordAction,
|
mutationFn: ForgotPasswordAction,
|
||||||
onSuccess: ({ message }) => {
|
onSuccess: ({ message }) => {
|
||||||
form.reset();
|
form.reset();
|
||||||
toast.success(message);
|
toast(message);
|
||||||
},
|
|
||||||
onError: ({ message }) => {
|
|
||||||
form.reset();
|
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
>;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from '../_validation/signin-validation';
|
} from '../_validation/signin-validation';
|
||||||
|
|
||||||
export async function SigninAction(request: SignInValidationType) {
|
export async function SigninAction(request: SignInValidationType) {
|
||||||
|
console.log(request);
|
||||||
|
|
||||||
const validRequest = signInValidationSchema.parse(request);
|
const validRequest = signInValidationSchema.parse(request);
|
||||||
|
|
||||||
const { data } = await fetchPostSignin(validRequest);
|
const { data } = await fetchPostSignin(validRequest);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { SigninAction } from '../_actions/signin-action';
|
import { SigninAction } from '../_actions/signin-action';
|
||||||
import { useFormSignin } from './use-form-signin';
|
import { useFormSignin } from './use-form-signin';
|
||||||
|
|
||||||
@@ -9,20 +8,11 @@ export function usePostSignin(form: ReturnType<typeof useFormSignin>) {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: SigninAction,
|
mutationFn: SigninAction,
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
},
|
},
|
||||||
onError: (error, variables) => {
|
onError: () => {
|
||||||
form.resetField('password');
|
form.resetField('password');
|
||||||
|
|
||||||
if (error.message.includes('not active')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push(`/verification?ref=${variables.email}`);
|
|
||||||
}, 750);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,20 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getRemoteIp } from '@/lib/headers';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
|
||||||
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
||||||
|
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
|
||||||
import { signupValidationSchema } from '../_validation/signup-validation';
|
import { signupValidationSchema } from '../_validation/signup-validation';
|
||||||
|
|
||||||
export async function SignupAction(
|
export async function SignupAction(
|
||||||
request: z.infer<typeof signupValidationSchema>
|
request: z.infer<typeof signupValidationSchema>
|
||||||
) {
|
) {
|
||||||
const validRequest = signupValidationSchema.parse(request);
|
const validRequest = signupValidationSchema.parse(request);
|
||||||
const remoteIp = await getRemoteIp();
|
|
||||||
|
|
||||||
const isCapchaValid = await fetchPostverifyTurnstile(
|
const isCapchaValidationValid = await fetchPostverifyTurnstile(
|
||||||
validRequest.token,
|
validRequest.token
|
||||||
remoteIp
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
const data = await fetchPostSignin(validRequest);
|
const data = await fetchPostSignin(validRequest);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { fetcher } from '@/lib/fetcher';
|
import { fetcher } from '@/lib/fetcher';
|
||||||
import { getRemoteIp } from '@/lib/headers';
|
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
|
||||||
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
|
||||||
import {
|
import {
|
||||||
resendOTPValidationSchema,
|
resendOTPValidationSchema,
|
||||||
type ResendOTPValidationType,
|
type ResendOTPValidationType,
|
||||||
@@ -10,22 +9,20 @@ import {
|
|||||||
|
|
||||||
export async function resendOTPAction(request: ResendOTPValidationType) {
|
export async function resendOTPAction(request: ResendOTPValidationType) {
|
||||||
const validRequest = resendOTPValidationSchema.parse(request);
|
const validRequest = resendOTPValidationSchema.parse(request);
|
||||||
const remoteIp = await getRemoteIp();
|
|
||||||
|
|
||||||
const isCapchaValid = await fetchPostverifyTurnstile(
|
const isCapchaValidationValid = await fetchPostverifyTurnstile(
|
||||||
validRequest.token,
|
validRequest.token
|
||||||
remoteIp
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
const { data, error } = await fetcher.POST('/v1/auth/send-otp', {
|
const { data } = await fetcher.POST('/v1/auth/send-otp', {
|
||||||
body: {
|
body: {
|
||||||
email: validRequest.email,
|
email: validRequest.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw new Error(error.message);
|
console.log(data);
|
||||||
|
|
||||||
return data;
|
return data?.message;
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ export function VerificationTabs() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => setActiveTab('resend')}
|
onClick={() => setActiveTab('resend')}
|
||||||
>
|
>
|
||||||
Resend OTP
|
Kirim Ulang OTP
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { useRouter } from 'next/navigation';
|
||||||
import { resendOTPAction } from '../_actions/resend-otp-action';
|
import { resendOTPAction } from '../_actions/resend-otp-action';
|
||||||
import { useFormResendOTP } from './use-form-resend-otp';
|
import { useFormResendOTP } from './use-form-resend-otp';
|
||||||
|
|
||||||
export function usePostResendOTP(form: ReturnType<typeof useFormResendOTP>) {
|
export function usePostResendOTP(form: ReturnType<typeof useFormResendOTP>) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: resendOTPAction,
|
mutationFn: resendOTPAction,
|
||||||
onSuccess: ({ message }) => {
|
onSuccess: () => router.replace('/verification?success=true'),
|
||||||
form.reset();
|
|
||||||
toast.success(message);
|
|
||||||
},
|
|
||||||
onError: ({ message }) => {
|
|
||||||
form.reset();
|
|
||||||
toast.error(message);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
import { LogoSimple } from '../../_components/logo';
|
import { LogoSimple } from '../../_components/logo';
|
||||||
import { VerificationTabs } from './_components/verification-tabs';
|
import { VerificationTabs } from './_components/verification-tabs';
|
||||||
|
|
||||||
@@ -13,9 +12,7 @@ export default function Page() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<VerificationTabs />
|
||||||
<VerificationTabs />
|
|
||||||
</Suspense>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import HERO_STATS from '@/data/hero-stats.json';
|
||||||
|
import { Button, SparklesIcon } from '@components';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Fragment, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollY(window.scrollY);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative w-full py-20 md:py-32 lg:py-40 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-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${scrollY * 0.1}px, ${scrollY * -0.05}px)`,
|
||||||
|
opacity: Math.max(0.2, 1 - scrollY * 0.001),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${scrollY * -0.1}px, ${scrollY * 0.05}px)`,
|
||||||
|
opacity: Math.max(0.2, 1 - scrollY * 0.001),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container px-4 md:px-6 relative">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col justify-center space-y-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div 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" />
|
||||||
|
<span>Komunitas Programmer Indonesia</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Programmer Handal, <br />
|
||||||
|
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||||
|
Tanpa Ribet
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-[600px] text-muted-foreground md:text-xl">
|
||||||
|
Temukan potensi programming Anda bersama komunitas yang
|
||||||
|
mendukung, tutorial interaktif, dan sumber daya berkualitas
|
||||||
|
tinggi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||||
|
<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://facebook.com/groups/programmerhandal',
|
||||||
|
'_blank'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Mulai Belajar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto group relative overflow-hidden border-primary"
|
||||||
|
onClick={() =>
|
||||||
|
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="relative">Gabung Discord</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-start gap-6 sm:gap-8">
|
||||||
|
{HERO_STATS.map(({ value, label }, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<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">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
</div>
|
||||||
|
{i < HERO_STATS.length - 1 && (
|
||||||
|
<div className="hidden sm:block h-10 border-r border-border mx-4" />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full lg:w-auto mx-auto lg:ml-auto"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/logo.webp"
|
||||||
|
alt="logo"
|
||||||
|
width={600}
|
||||||
|
height={500}
|
||||||
|
className="w-full h-auto object-cover"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||