Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb6f53a76 | ||
|
|
f0a5c89407 | ||
|
|
989d0fc21b | ||
|
|
98ce76f496 | ||
|
|
25ca86f100 | ||
|
|
76727c0762 | ||
|
|
7dd13fce9e | ||
|
|
a33c1a56a8 | ||
|
|
868b0c02df | ||
|
|
3d69d34a7e | ||
|
|
738b0b5728 | ||
|
|
edc8fff13a |
@@ -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: 20 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 |
|
Before Width: | Height: | Size: 351 KiB |
|
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';
|
|
||||||
@@ -12,13 +12,12 @@ export const useLogin = () => {
|
|||||||
mode: 'all',
|
mode: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { signIn, isLoading } = useSession();
|
const { signIn } = useSession();
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
const onSubmit = form.handleSubmit((data) => signIn(data));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import {
|
|
||||||
TVerifyOtpRequest,
|
|
||||||
verifyEmailSchema,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useOtp } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
export const useOtpHook = () => {
|
|
||||||
const form = useForm<TVerifyOtpRequest>({
|
|
||||||
resolver: zodResolver(verifyEmailSchema),
|
|
||||||
mode: 'all',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { otp, isLoading } = useOtp();
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => otp(data));
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
onSubmit,
|
|
||||||
isLoading
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import {
|
|
||||||
authRegisterSchema,
|
|
||||||
TRegisterRequest,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useRegister } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
export const useRegisterHook = () => {
|
|
||||||
const form = useForm<TRegisterRequest>({
|
|
||||||
resolver: zodResolver(authRegisterSchema),
|
|
||||||
mode: 'all',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { register, isLoading } = useRegister();
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => register(data));
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
onSubmit,
|
|
||||||
isLoading
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useSendOTP } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
export const useResendOtpHook = () => {
|
|
||||||
|
|
||||||
const { resendOTP, isLoading } = useSendOTP();
|
|
||||||
|
|
||||||
return {
|
|
||||||
resendOTP
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@ import { useLogin } from '../../_hooks/use-login';
|
|||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
const { form, onSubmit, isLoading } = useLogin();
|
const { form, onSubmit } = useLogin();
|
||||||
|
|
||||||
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]">
|
||||||
@@ -42,7 +42,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
Lupa Password ?
|
Lupa Password ?
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button>
|
<Button className="w-full" type='submit'>Enter Isekai</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="flex my-3 gap-3 justify-center">
|
<div className="flex my-3 gap-3 justify-center">
|
||||||
@@ -74,13 +74,6 @@ export const Components: FC = (): ReactElement => {
|
|||||||
<ArrowLeftOutlined />
|
<ArrowLeftOutlined />
|
||||||
Kembali Ke Homepage
|
Kembali Ke Homepage
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
className='xl:hidden w-full gap-3'
|
|
||||||
variant='secondary'
|
|
||||||
>
|
|
||||||
<ArrowLeftOutlined />
|
|
||||||
Kembali Ke Homepage
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
|
||||||
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
|
|
||||||
import { useOtpHook } from '../../../_hooks/use-otp';
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { useResendOtpHook } from '../../../_hooks/use-resend-otp';
|
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
|
||||||
const { form, onSubmit, isLoading } = useOtpHook()
|
|
||||||
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 (
|
|
||||||
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
|
|
||||||
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
|
|
||||||
<RegisterResetBanner />
|
|
||||||
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
|
|
||||||
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Verifikasi Email</h3>
|
|
||||||
<h5 className='mt-2 text-xl font-medium text-primary-500'>Yeay~! Pesan dari dunia lain sudah dikirimkan ke {searchParams.get("email")}</h5>
|
|
||||||
<form onSubmit={onSubmit} className='mt-7'>
|
|
||||||
<ControlledInputField
|
|
||||||
label="OTP"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
placeholder="XXXXXX"
|
|
||||||
max={6}
|
|
||||||
name={'otp'}
|
|
||||||
maxLength={6}
|
|
||||||
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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Components;
|
|
||||||
@@ -1,108 +1,96 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement } from 'react';
|
||||||
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
|
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
import { useRegisterHook } from '../../_hooks/use-register';
|
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
const { form, onSubmit, isLoading } = useRegisterHook();
|
|
||||||
|
|
||||||
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]">
|
||||||
<div className="bg-white xl:min-w-[1130px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
<div className="bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
||||||
<RegisterResetBanner />
|
<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="border-2 border-primary-500/50 w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center">
|
||||||
<div className="xl:w-[714px]">
|
<div className="w-[704px]">
|
||||||
<Button
|
|
||||||
className='xl:hidden gap-3'
|
|
||||||
variant='secondary'
|
|
||||||
>
|
|
||||||
<ArrowLeftOutlined />
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
|
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
|
||||||
Register
|
Register
|
||||||
</h3>
|
</h3>
|
||||||
<h5 className="text-primary-500 font-medium">
|
<h5 className="text-primary-500 font-medium">
|
||||||
Yosha~! Saatnya Bergabung dengan Dimentorin
|
Yosha~! Saatnya Bergabung dengan Dimentorin
|
||||||
</h5>
|
</h5>
|
||||||
<form onSubmit={onSubmit}>
|
<div className="grid grid-flow-row-dense my-7 grid-cols-2 gap-2">
|
||||||
<div className='my-7'>
|
<InputField
|
||||||
<ControlledInputField
|
label="First Name"
|
||||||
label="Full name"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
placeholder="Nama Lengkap"
|
|
||||||
name={'fullname'}
|
|
||||||
control={form.control}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-flow-row-dense my-7 lg:grid-cols-2 gap-2">
|
|
||||||
<ControlledInputField
|
|
||||||
label="Email"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
placeholder="Contoh : yourname@mail.com"
|
|
||||||
type='email'
|
|
||||||
control={form.control}
|
|
||||||
name={'email'}
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
label="No Hp"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
placeholder="Contoh : 088877665544"
|
|
||||||
control={form.control}
|
|
||||||
name={'phone_number'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ControlledInputField
|
|
||||||
label="Password"
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
size="lg"
|
||||||
type="password"
|
placeholder="Nama Depan"
|
||||||
placeholder="Buat password sekeren jurus ultimate-mu!"
|
|
||||||
control={form.control}
|
|
||||||
name={'password'}
|
|
||||||
/>
|
/>
|
||||||
<h6 className="text-sm text-gray-500 mt-1">
|
<InputField
|
||||||
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
|
label="Last Name"
|
||||||
</h6>
|
|
||||||
<h6 className="text-sm text-gray-500 mt-2">
|
|
||||||
Tips membuat password yang OP:
|
|
||||||
</h6>
|
|
||||||
<h6 className="text-sm text-gray-500">
|
|
||||||
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
|
|
||||||
<span role="img" aria-label="emoji">
|
|
||||||
⚡
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
</h6>
|
|
||||||
<h6 className="text-sm text-gray-500">
|
|
||||||
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
|
|
||||||
ultimate!
|
|
||||||
<span role="img" aria-label="emoji">
|
|
||||||
🔥
|
|
||||||
</span>
|
|
||||||
</h6>
|
|
||||||
<h6 className="text-sm text-gray-500 mb-2">
|
|
||||||
- Jangan pakai password yang gampang ditebak, nanti ketahuan
|
|
||||||
musuh!
|
|
||||||
<span role="img" aria-label="emoji">
|
|
||||||
🚨
|
|
||||||
</span>
|
|
||||||
</h6>
|
|
||||||
<ControlledInputField
|
|
||||||
label="Repeat Password"
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
size="lg"
|
||||||
type="password"
|
className="w-full"
|
||||||
placeholder="Ulangi password-mu, Senpai~!"
|
placeholder="Nama Belakang"
|
||||||
control={form.control}
|
|
||||||
name={"confirm_password"}
|
|
||||||
/>
|
/>
|
||||||
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
|
<InputField
|
||||||
</form>
|
label="Email"
|
||||||
|
size="lg"
|
||||||
|
placeholder="Contoh : yourname@mail.com"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="font-medium">OTP Code</h4>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
placeholder="Kode OTP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Button size="sm" className="mt-[30px]">
|
||||||
|
Kirim OTP
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputField
|
||||||
|
label="Password"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
type="password"
|
||||||
|
placeholder="Buat password sekeren jurus ultimate-mu!"
|
||||||
|
/>
|
||||||
|
<h6 className="text-sm text-gray-500 mt-1">
|
||||||
|
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
|
||||||
|
</h6>
|
||||||
|
<h6 className="text-sm text-gray-500 mt-2">
|
||||||
|
Tips membuat password yang OP:
|
||||||
|
</h6>
|
||||||
|
<h6 className="text-sm text-gray-500">
|
||||||
|
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
|
||||||
|
<span role="img" aria-label="emoji">
|
||||||
|
⚡
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
</h6>
|
||||||
|
<h6 className="text-sm text-gray-500">
|
||||||
|
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
|
||||||
|
ultimate!
|
||||||
|
<span role="img" aria-label="emoji">
|
||||||
|
🔥
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
<h6 className="text-sm text-gray-500 mb-2">
|
||||||
|
- Jangan pakai password yang gampang ditebak, nanti ketahuan
|
||||||
|
musuh!
|
||||||
|
<span role="img" aria-label="emoji">
|
||||||
|
🚨
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
<InputField
|
||||||
|
label="Repeat Password"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
type="password"
|
||||||
|
placeholder="Ulangi password-mu, Senpai~!"
|
||||||
|
/>
|
||||||
|
<Button className="w-full mt-2">Linked Start !!!</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { FC, ReactElement, useEffect } from 'react';
|
import { FC, ReactElement } from 'react';
|
||||||
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
|
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { ArrowRightOutlined } from '@ant-design/icons';
|
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
document.location.href = "/auth/login"
|
|
||||||
}, 10000)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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]">
|
||||||
<div className="bg-white xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
<div className="bg-white min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
||||||
<RegisterResetBanner />
|
<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="border-2 border-primary-500/50 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">
|
<div className="w-[704px] flex flex-col justify-center items-center">
|
||||||
<h2 className="text-4xl text-primary-500 font-semibold">
|
<h2 className="text-4xl text-primary-500 font-semibold">
|
||||||
Register Successfull
|
Register Successfull
|
||||||
</h2>
|
</h2>
|
||||||
@@ -47,12 +41,10 @@ export const Components: FC = (): ReactElement => {
|
|||||||
<h4 className="mt-10 text-primary-500 font-medium text-xl">
|
<h4 className="mt-10 text-primary-500 font-medium text-xl">
|
||||||
Kamu akan memasuki isekai dalam 10 dtk
|
Kamu akan memasuki isekai dalam 10 dtk
|
||||||
</h4>
|
</h4>
|
||||||
<a href="/auth/login">
|
<Button className="gap-3 mt-10" size="lg">
|
||||||
<Button className="gap-3 mt-10" size="lg">
|
Masuk Isekai
|
||||||
Masuk Isekai
|
<ArrowRightOutlined />
|
||||||
<ArrowRightOutlined />
|
</Button>
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ const mappingPublicRoutes = [
|
|||||||
'/',
|
'/',
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
'/auth/forgot',
|
'/auth/forgot',
|
||||||
'/auth/forgot/otp',
|
|
||||||
'/auth/register',
|
|
||||||
'/auth/register/otp',
|
|
||||||
'/auth/register/success',
|
|
||||||
'/auth/new-password',
|
'/auth/new-password',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Button } from '@imphnen-frontend-service/ui/atoms';
|
|||||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { useLogin } from '../../_hooks/use-login';
|
import { useLogin } from '../../_hooks/use-login';
|
||||||
import ModalFormVerifyEmail from './modal-form-verify-email';
|
|
||||||
|
|
||||||
interface IModalFormLogin {
|
interface IModalFormLogin {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -17,87 +16,61 @@ const ModalFormLogin = ({
|
|||||||
onForgotPassword,
|
onForgotPassword,
|
||||||
setIsOpenRegisterModal,
|
setIsOpenRegisterModal,
|
||||||
}: IModalFormLogin) => {
|
}: IModalFormLogin) => {
|
||||||
const {
|
const { form, onSubmit } = useLogin();
|
||||||
form,
|
|
||||||
onSubmit,
|
|
||||||
isLoading,
|
|
||||||
showVerifyModal,
|
|
||||||
verifyForm,
|
|
||||||
onVerifySubmit,
|
|
||||||
closeVerifyModal,
|
|
||||||
isVerifying,
|
|
||||||
emailToVerify,
|
|
||||||
} = useLogin();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Modal
|
||||||
<Modal
|
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
||||||
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
isOpen={isOpen}
|
||||||
isOpen={isOpen && !showVerifyModal}
|
onClose={onClose}
|
||||||
onClose={onClose}
|
>
|
||||||
>
|
<Modal.Header className="space-y-4">
|
||||||
<Modal.Header className="space-y-4">
|
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
||||||
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
||||||
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
Login
|
||||||
Login
|
</h1>
|
||||||
</h1>
|
</Modal.Header>
|
||||||
</Modal.Header>
|
<Modal.Content>
|
||||||
<Modal.Content>
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
<form className="space-y-4" onSubmit={onSubmit}>
|
<ControlledInputField
|
||||||
<ControlledInputField
|
control={form.control}
|
||||||
control={form.control}
|
label="Email"
|
||||||
label="Email"
|
placeholder="Masukkan Email"
|
||||||
placeholder="Masukkan Email"
|
type="email"
|
||||||
type="email"
|
name="email"
|
||||||
name="email"
|
size="lg"
|
||||||
size="lg"
|
className="w-full"
|
||||||
className="w-full"
|
/>
|
||||||
/>
|
<ControlledInputField
|
||||||
<ControlledInputField
|
control={form.control}
|
||||||
control={form.control}
|
label="Password"
|
||||||
label="Password"
|
placeholder="Masukkan Password"
|
||||||
placeholder="Masukkan Password"
|
type="password"
|
||||||
type="password"
|
name="password"
|
||||||
name="password"
|
size="lg"
|
||||||
size="lg"
|
className="w-full"
|
||||||
className="w-full"
|
/>
|
||||||
/>
|
<h1 className="text-end text-primary-500 text-xl font-medium">
|
||||||
<h1 className="text-end text-primary-500 text-xl font-medium">
|
<Button variant="text" onClick={onForgotPassword}>
|
||||||
<Button variant="text" type="button" onClick={onForgotPassword}>
|
Lupa Password?
|
||||||
Lupa Password?
|
|
||||||
</Button>
|
|
||||||
</h1>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="md"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Loading...' : 'Login'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex justify-center gap-2 pt-2.5 font-medium">
|
</h1>
|
||||||
<p className="text-neutral-500">Belum punya akun?</p>
|
<Button type="submit" size="md" className="w-full">
|
||||||
<Button
|
Login
|
||||||
variant="text"
|
</Button>
|
||||||
className="text-primary-500 hover:text-primary-600 m-0 p-0"
|
<div className="flex justify-center gap-2 pt-2.5 font-medium">
|
||||||
onClick={() => setIsOpenRegisterModal(true)}
|
<p className="text-neutral-500">Belum punya akun?</p>
|
||||||
>
|
<Button
|
||||||
Daftar
|
variant="text"
|
||||||
</Button>
|
className="text-primary-500 hover:text-primary-600 m-0 p-0"
|
||||||
</div>
|
onClick={() => setIsOpenRegisterModal(true)}
|
||||||
</form>
|
>
|
||||||
</Modal.Content>
|
Daftar
|
||||||
</Modal>
|
</Button>
|
||||||
|
</div>
|
||||||
<ModalFormVerifyEmail
|
</form>
|
||||||
isOpen={showVerifyModal}
|
</Modal.Content>
|
||||||
onClose={closeVerifyModal}
|
</Modal>
|
||||||
verifyForm={verifyForm}
|
|
||||||
onVerifySubmit={onVerifySubmit}
|
|
||||||
isVerifying={isVerifying}
|
|
||||||
email={emailToVerify}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
import { useRegister } from '../../_hooks/use-register';
|
import { useRegister } from '../../_hooks/use-register';
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import ModalFormVerifyEmail from './modal-form-verify-email';
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
|
import { TRegisterRequest } from '@imphnen-frontend-service/service';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
interface IModalFormRegisterProps {
|
interface IModalFormRegisterProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -10,100 +13,168 @@ interface IModalFormRegisterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
|
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
|
||||||
|
const { form, onSubmit, isStepOneValid } = useRegister();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
form,
|
step: currentStep,
|
||||||
verifyForm,
|
nextStep,
|
||||||
onSubmit,
|
prevStep,
|
||||||
onVerifySubmit,
|
resetStep,
|
||||||
showVerifyModal,
|
} = useQueryState('registerStep', {
|
||||||
closeVerifyModal,
|
defaultValue: 1,
|
||||||
isVerifying,
|
maxValue: 2,
|
||||||
isRegistering,
|
minValue: 1,
|
||||||
registeredEmail,
|
});
|
||||||
} = useRegister();
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStepOneValid && currentStep === 2) {
|
||||||
|
prevStep();
|
||||||
|
}
|
||||||
|
}, [isStepOneValid, currentStep, prevStep]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Modal
|
||||||
<Modal
|
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
||||||
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
isOpen={isOpen}
|
||||||
isOpen={isOpen && !showVerifyModal}
|
onClose={() => {
|
||||||
onClose={onClose}
|
onClose();
|
||||||
>
|
resetStep();
|
||||||
<Modal.Header className="space-y-4">
|
}}
|
||||||
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
>
|
||||||
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
<Modal.Header className="space-y-4">
|
||||||
Register
|
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
||||||
</h1>
|
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
||||||
</Modal.Header>
|
Register
|
||||||
<Modal.Content>
|
</h1>
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<h2 className="text-neutral-500 text-lg text-center">
|
||||||
<ControlledInputField
|
{currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'}
|
||||||
control={form.control}
|
</h2>
|
||||||
name="fullname"
|
</Modal.Header>
|
||||||
label="Nama Lengkap"
|
<Modal.Content>
|
||||||
placeholder="Masukkan Nama Lengkap"
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
type="text"
|
{currentStep === 1 && (
|
||||||
size="lg"
|
<StepOne
|
||||||
className="w-full"
|
form={form}
|
||||||
|
nextStep={nextStep}
|
||||||
|
isStepOneValid={isStepOneValid}
|
||||||
/>
|
/>
|
||||||
<ControlledInputField
|
)}
|
||||||
control={form.control}
|
{currentStep === 2 && <StepTwo form={form} prevStep={prevStep} />}
|
||||||
label="Email"
|
</form>
|
||||||
placeholder="Masukkan Email Anda"
|
</Modal.Content>
|
||||||
type="email"
|
</Modal>
|
||||||
name="email"
|
);
|
||||||
size="lg"
|
};
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
label="Password"
|
|
||||||
placeholder="Masukkan Password"
|
|
||||||
type="password"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
name="confirm_password"
|
|
||||||
label="Ulangi Password"
|
|
||||||
placeholder="Masukkan Ulang Password"
|
|
||||||
type="password"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
name="phone_number"
|
|
||||||
label="Nomor Telepon"
|
|
||||||
placeholder="Masukkan Nomor Telepon Aktif"
|
|
||||||
type="text"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="md"
|
|
||||||
className="w-full"
|
|
||||||
type="submit"
|
|
||||||
disabled={isRegistering || !form.formState.isValid}
|
|
||||||
>
|
|
||||||
{isRegistering ? 'Mendaftar...' : 'Register'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Modal.Content>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<ModalFormVerifyEmail
|
interface IStepOneProps {
|
||||||
isOpen={showVerifyModal}
|
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
|
||||||
onClose={closeVerifyModal}
|
nextStep: () => void;
|
||||||
verifyForm={verifyForm}
|
isStepOneValid: boolean;
|
||||||
onVerifySubmit={onVerifySubmit}
|
}
|
||||||
isVerifying={isVerifying}
|
|
||||||
email={registeredEmail}
|
const StepOne = ({ form, nextStep, isStepOneValid }: IStepOneProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
name="fullname"
|
||||||
|
label="Nama Lengkap"
|
||||||
|
placeholder="Masukkan Nama Lengkap"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Email"
|
||||||
|
placeholder="Masukkan Email Anda"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder="Masukkan Password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
name="confirm_password"
|
||||||
|
label="Ulangi Password"
|
||||||
|
placeholder="Masukkan Ulang Password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={!isStepOneValid}
|
||||||
|
>
|
||||||
|
Selanjutnya
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
|
||||||
|
prevStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ form, prevStep }: IStepTwoProps) => (
|
||||||
|
<>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
name="phone_number"
|
||||||
|
label="Nomor Telepon"
|
||||||
|
placeholder="Masukkan Nomor Telepon Aktif"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
name="referral_code"
|
||||||
|
label="Kode Referral"
|
||||||
|
placeholder="Masukkan Kode Referral"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
name="referred_by"
|
||||||
|
label="Referral By"
|
||||||
|
placeholder="Masukkan Referral By"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
name="student_type"
|
||||||
|
label="Tipe Pelajar"
|
||||||
|
placeholder="Masukkan Tipe Pelajar"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<Button size="md" variant="text" className="w-[40%]" onClick={prevStep}>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
<Button size="md" className="w-full" type="submit">
|
||||||
|
Gacha Sekarang
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export default ModalFormRegister;
|
export default ModalFormRegister;
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
import { UseFormReturn } from 'react-hook-form';
|
|
||||||
|
|
||||||
interface IModalFormVerifyEmailProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
verifyForm: UseFormReturn<{ otp: string }, any>;
|
|
||||||
onVerifySubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
|
||||||
isVerifying: boolean;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModalFormVerifyEmail = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
verifyForm,
|
|
||||||
onVerifySubmit,
|
|
||||||
isVerifying,
|
|
||||||
email,
|
|
||||||
}: IModalFormVerifyEmailProps) => {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<Modal.Header className="space-y-4">
|
|
||||||
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
|
||||||
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
|
||||||
Verifikasi Email
|
|
||||||
</h1>
|
|
||||||
<h2 className="text-neutral-500 text-lg text-center">
|
|
||||||
Masukkan kode OTP yang telah dikirim ke {email ? email : 'email Anda'}
|
|
||||||
</h2>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Content>
|
|
||||||
<form onSubmit={onVerifySubmit} className="space-y-6">
|
|
||||||
<ControlledInputField
|
|
||||||
control={verifyForm.control}
|
|
||||||
name="otp"
|
|
||||||
label="Kode OTP"
|
|
||||||
placeholder="Masukkan Kode OTP"
|
|
||||||
type="text"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="md"
|
|
||||||
className="w-full"
|
|
||||||
type="submit"
|
|
||||||
disabled={isVerifying}
|
|
||||||
>
|
|
||||||
{isVerifying ? 'Memverifikasi...' : 'Verifikasi'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Modal.Content>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModalFormVerifyEmail;
|
|
||||||
@@ -2,13 +2,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import {
|
import {
|
||||||
authLoginSchema,
|
authLoginSchema,
|
||||||
TLoginRequest,
|
TLoginRequest,
|
||||||
usePostLogin,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
import { useSession } from '@imphnen-frontend-service/utils';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { useVerifyEmail } from './use-verify-email';
|
|
||||||
|
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
const form = useForm<TLoginRequest>({
|
const form = useForm<TLoginRequest>({
|
||||||
@@ -16,63 +12,12 @@ export const useLogin = () => {
|
|||||||
mode: 'all',
|
mode: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const { signIn } = useSession();
|
||||||
const { mutate, isPending } = usePostLogin();
|
|
||||||
const { setLoading, setSession, clearSession } = useAuthStore();
|
|
||||||
|
|
||||||
const {
|
const onSubmit = form.handleSubmit((data) => signIn(data));
|
||||||
openVerifyModal,
|
|
||||||
showVerifyModal,
|
|
||||||
verifyForm,
|
|
||||||
onVerifySubmit,
|
|
||||||
closeVerifyModal,
|
|
||||||
isVerifying,
|
|
||||||
emailToVerify,
|
|
||||||
} = useVerifyEmail();
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
|
||||||
setLoading(true);
|
|
||||||
mutate(data, {
|
|
||||||
onSuccess: (response) => {
|
|
||||||
toast.success('Login sukses');
|
|
||||||
|
|
||||||
if (response.data.token && response.data.user) {
|
|
||||||
setSession(response.data);
|
|
||||||
} else {
|
|
||||||
const { token, ...userData } = response.data;
|
|
||||||
|
|
||||||
const loginData = {
|
|
||||||
token,
|
|
||||||
userData,
|
|
||||||
};
|
|
||||||
setSession(loginData);
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(0);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error?.response?.data?.message;
|
|
||||||
|
|
||||||
if (errorMessage === 'Account not active, please verify your email') {
|
|
||||||
toast.error('Akun belum aktif, silakan verifikasi email Anda');
|
|
||||||
openVerifyModal(data.email);
|
|
||||||
} else {
|
|
||||||
toast.error(errorMessage ?? 'Terjadi Kesalahan yang tidak diketahui');
|
|
||||||
clearSession();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading: isPending,
|
|
||||||
showVerifyModal,
|
|
||||||
verifyForm,
|
|
||||||
onVerifySubmit,
|
|
||||||
closeVerifyModal,
|
|
||||||
isVerifying,
|
|
||||||
emailToVerify,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,20 +6,9 @@ import {
|
|||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useVerifyEmail } from './use-verify-email';
|
|
||||||
|
|
||||||
export const useRegister = () => {
|
export const useRegister = () => {
|
||||||
const postRegister = usePostRegister();
|
const postRegister = usePostRegister();
|
||||||
const {
|
|
||||||
verifyForm,
|
|
||||||
onVerifySubmit,
|
|
||||||
showVerifyModal,
|
|
||||||
openVerifyModal,
|
|
||||||
closeVerifyModal,
|
|
||||||
isVerifying,
|
|
||||||
emailToVerify,
|
|
||||||
} = useVerifyEmail();
|
|
||||||
|
|
||||||
const form = useForm<TRegisterRequest>({
|
const form = useForm<TRegisterRequest>({
|
||||||
resolver: zodResolver(authRegisterSchema),
|
resolver: zodResolver(authRegisterSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
@@ -34,23 +23,13 @@ export const useRegister = () => {
|
|||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
postRegister.mutate(data, {
|
postRegister.mutate(data, {
|
||||||
onSuccess: () => {
|
onSuccess: () => toast.success('Registrasi sukses'),
|
||||||
toast.success('Registrasi sukses. Silakan verifikasi email Anda.');
|
onError: (error) => toast.error(error.message),
|
||||||
openVerifyModal(data.email);
|
|
||||||
},
|
|
||||||
onError: (error) => toast.error(error.message || 'Registrasi gagal'),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
verifyForm,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onVerifySubmit,
|
|
||||||
showVerifyModal,
|
|
||||||
closeVerifyModal,
|
|
||||||
isVerifying,
|
|
||||||
isRegistering: postRegister.isPending,
|
|
||||||
registeredEmail: emailToVerify,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import {
|
|
||||||
TVerifyEmailRequest,
|
|
||||||
usePostVerifyEmail,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export const useVerifyEmail = () => {
|
|
||||||
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
|
||||||
const [emailToVerify, setEmailToVerify] = useState('');
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const postVerifyEmail = usePostVerifyEmail();
|
|
||||||
|
|
||||||
const verifyForm = useForm<{ otp: string }>({
|
|
||||||
mode: 'all',
|
|
||||||
defaultValues: {
|
|
||||||
otp: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onVerifySubmit = verifyForm.handleSubmit((data) => {
|
|
||||||
const otpNumber = data.otp;
|
|
||||||
|
|
||||||
const verifyData: TVerifyEmailRequest = {
|
|
||||||
email: emailToVerify,
|
|
||||||
otp: otpNumber,
|
|
||||||
};
|
|
||||||
|
|
||||||
postVerifyEmail.mutate(verifyData, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Verifikasi email sukses');
|
|
||||||
|
|
||||||
navigate(0);
|
|
||||||
},
|
|
||||||
onError: (error) =>
|
|
||||||
toast.error(error.message || 'Verifikasi email gagal'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const openVerifyModal = (email: string) => {
|
|
||||||
setEmailToVerify(email);
|
|
||||||
setShowVerifyModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeVerifyModal = () => {
|
|
||||||
setShowVerifyModal(false);
|
|
||||||
verifyForm.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
verifyForm,
|
|
||||||
onVerifySubmit,
|
|
||||||
showVerifyModal,
|
|
||||||
openVerifyModal,
|
|
||||||
closeVerifyModal,
|
|
||||||
isVerifying: postVerifyEmail.isPending,
|
|
||||||
emailToVerify,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -66,15 +66,11 @@ export const Components: FC = (): ReactElement => {
|
|||||||
<div className="order-1 lg:order-none flex flex-col col-span-4 justify-center items-center mb-8 relative">
|
<div className="order-1 lg:order-none flex flex-col col-span-4 justify-center items-center mb-8 relative">
|
||||||
<div className="relative h-[150px] md:h-[230px]">
|
<div className="relative h-[150px] md:h-[230px]">
|
||||||
<img
|
<img
|
||||||
width={400}
|
|
||||||
height={400}
|
|
||||||
src="/merch/1.png"
|
src="/merch/1.png"
|
||||||
alt="Merch 1"
|
alt="Merch 1"
|
||||||
className="relative top-[3px] md:top-[6px] left-[4px] md:left-[7px] z-10 w-[165px] md:w-[266px]"
|
className="relative top-[3px] md:top-[6px] left-[4px] md:left-[7px] z-10 w-[165px] md:w-[266px]"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
width={400}
|
|
||||||
height={400}
|
|
||||||
className="absolute top-0 left-0 min-w-[174px] md:min-w-[280px] z-0"
|
className="absolute top-0 left-0 min-w-[174px] md:min-w-[280px] z-0"
|
||||||
src="/merch/Vector-1.svg"
|
src="/merch/Vector-1.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -92,7 +88,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
~ 175k ~
|
~ 175k ~
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative">
|
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative overflow-hidden">
|
||||||
<div className="relative h-[130px] md:h-[230px]">
|
<div className="relative h-[130px] md:h-[230px]">
|
||||||
<img
|
<img
|
||||||
width={400}
|
width={400}
|
||||||
@@ -109,6 +105,8 @@ export const Components: FC = (): ReactElement => {
|
|||||||
alt="Merch 3"
|
alt="Merch 3"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
className="absolute hidden lg:block -bottom-[100px] -left-[55px]"
|
className="absolute hidden lg:block -bottom-[100px] -left-[55px]"
|
||||||
src="/landing-arrow-2.svg"
|
src="/landing-arrow-2.svg"
|
||||||
alt="Merch 1"
|
alt="Merch 1"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ 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"/>
|
<Toaster />
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
@@ -1,15 +1,109 @@
|
|||||||
// import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
|
import {
|
||||||
import { LoaderFunctionArgs } from 'react-router';
|
PERMISSIONS,
|
||||||
|
SessionToken,
|
||||||
|
SessionUser,
|
||||||
|
} from '@imphnen-frontend-service/utils';
|
||||||
|
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
|
||||||
// const mappingPublicRoutes = ['/'];
|
const mappingPublicRoutes = [
|
||||||
|
'/',
|
||||||
|
'/auth/login',
|
||||||
|
'/auth/forgot',
|
||||||
|
'/auth/new-password',
|
||||||
|
];
|
||||||
|
|
||||||
// const mappingRoutePermissions = [
|
const mappingRoutePermissions = [
|
||||||
// {
|
{
|
||||||
// path: '/',
|
path: '/dashboard',
|
||||||
// permissions: [],
|
permissions: [],
|
||||||
// },
|
},
|
||||||
// ];
|
{
|
||||||
|
path: '/users',
|
||||||
|
permissions: [PERMISSIONS.USERS.READ_LIST],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/create',
|
||||||
|
permissions: [PERMISSIONS.USERS.CREATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/update',
|
||||||
|
permissions: [PERMISSIONS.USERS.UPDATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/detail',
|
||||||
|
permissions: [PERMISSIONS.USERS.READ_DETAIL],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles',
|
||||||
|
permissions: [PERMISSIONS.ROLES.READ_LIST],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles/create',
|
||||||
|
permissions: [PERMISSIONS.ROLES.CREATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles/update',
|
||||||
|
permissions: [PERMISSIONS.ROLES.UPDATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles/detail',
|
||||||
|
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions/create',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions/update',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions/detail',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
//TODO : Fix this later
|
||||||
|
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
|
||||||
|
// const fallback = mappingRoutePermissions.find((route) =>
|
||||||
|
// route.permissions.some((perm) => userPermissions.includes(perm))
|
||||||
|
// );
|
||||||
|
// return redirect(fallback?.path ?? '/auth/login');
|
||||||
|
// };
|
||||||
|
|
||||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const session = SessionUser.get();
|
||||||
|
const session_token = SessionToken.get();
|
||||||
|
const token = session_token?.token?.access_token;
|
||||||
|
const userPermissions =
|
||||||
|
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
|
if (mappingPublicRoutes.includes(pathname)) {
|
||||||
|
if (token) return redirect('/dashboard');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) return redirect('/auth/login');
|
||||||
|
|
||||||
|
const matchedRoute = mappingRoutePermissions.find(
|
||||||
|
(route) => route.path === pathname
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedRoute) {
|
||||||
|
const hasPermission =
|
||||||
|
!matchedRoute.permissions ||
|
||||||
|
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
return '/dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
NEXT_PUBLIC_API_URL=
|
NEXT_PUBLIC_API_URL=
|
||||||
|
|
||||||
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,86 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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,33 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||