Compare commits

...
27 Commits
Author SHA1 Message Date
boboiazumi e2be5105e9 feat: mentor status page pending and success 2025-07-12 22:38:23 +07:00
boboiazumi eb5ff2e46c feat: mentor register pages 2025-07-12 22:15:43 +07:00
boboiazumi e1269f4daa fix: conflict 2025-07-10 10:18:44 +07:00
boboiazumi 487623e4fb Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge
2025-07-10 10:07:36 +07:00
boboiazumi eff44c42a1 stash 2025-07-10 10:07:26 +07:00
Muhamad Rijal ec13f2c693 feat(dimentorin): slicing landing page (#46)
* feat(landing): slicing navigation bar

* feat(landing): slicing hero section

* feat(landing): slicing what we offer section

* feat(landing): add animation on hero and what we offer section

* feat(landing): slicing testimonial section

* feat(landing): change the animation for what we offer section

* feat: add utility for readable looping and conditional rendering of components

* feat(landing): slicing faq section

* feat(landing): change border radius of card on `what we offer` and `testimonial` section

* feat(landing): slicing cta section

* feat: modify accordion component

* feat(landing): add shape background at faq section

* feat(landing): slicing footer

* feat: toggle header visibility on scroll
2025-06-30 09:24:37 +07:00
boboiazumi aa52b62ae7 fix: minor 2025-06-19 21:36:08 +07:00
I Made Ocy Darma Putra 302ee37ca1 feat(docker): all projects (#36)
* fix: docker

* fix landing

* chore(landing): add outputFileTracingRoot to next config

* ci: add github workflow for landing deployment via ssh and docker compose

* chore: add docker-compose file for landing app build and run

* feat docker
2025-06-19 11:17:21 +07:00
Naufal Azmi b7282f9c99 Resend OTP (#34)
* feat: responsive

* fix: success register

* feat: auth login responsive && auth login implementation

* fix: login page button disabled & register page

* fix: conflict

* fix: button disabled when loading

* feat: register and otp integration

* Update middleware.ts

* feat: OTP Resend
2025-06-18 18:50:06 +07:00
Rasyid 2c347d1cc5 feat: add ui for roadmap ui (#35)
* fix: correct link for Roadmap navigation item

* feat: add feature request and projects voting components

* fix: correct spelling of 'Certificate' in ProjectsVote component

* feat: add Request Feature popup with responsive dialog and drawer components

* feat: enhance Header component with mobile menu animations and improved navigation styling

* refactor: simplify Header component by removing unused scroll handling and improving mobile menu structure

* fix: replace motion span with static span for header underline

* fix: update FeatureRequestCTA text for clarity and adjust page layout margins

* fix: remove DialogDescription from RequestFeaturePopup and update textarea placeholder for clarity

* fix: remove DrawerDescription from RequestFeaturePopup for cleaner UI

* fix: remove unused icon exports from icons

* fix: replace XIcon with LuX in DialogClose for consistency

* fix: remove unused icon export from index for cleaner module structure
2025-06-18 18:40:19 +07:00
boboiazumi 3dec77bd0a Merge remote-tracking branch 'origin/DimentorinAuth' into DimentorinAuth
merge
2025-05-30 14:27:00 +07:00
boboiazumi ec81922001 fix: conflict 2025-05-30 14:26:04 +07:00
boboiazumi d7b70b21d1 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge
2025-05-30 14:20:02 +07:00
boboiazumi a7cf85dd66 feat: OTP Resend 2025-05-30 14:19:36 +07:00
Naufal Azmi bb22068856 Update middleware.ts 2025-05-26 20:25:07 +07:00
Naufal Azmi 209fa3fff1 Merge branch 'develop' into DimentorinAuth 2025-05-26 20:22:04 +07:00
boboiazumi 935d23887c feat: register and otp integration 2025-05-26 20:14:26 +07:00
boboiazumi 363f1582c9 fix: button disabled when loading 2025-05-26 17:05:02 +07:00
boboiazumi 1a9b47953a fix: conflict 2025-05-26 16:28:46 +07:00
boboiazumi 182ff7facc fix: conflict 2025-05-25 10:12:21 +07:00
boboiazumi af8968ac01 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge develop
2025-05-25 09:30:39 +07:00
boboiazumi 4bd66af0ff fix: login page button disabled & register page 2025-05-25 09:24:24 +07:00
boboiazumi 73826fb4c3 feat: auth login responsive && auth login implementation 2025-04-08 12:28:18 +07:00
boboiazumi 2f9407e258 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop 2025-04-07 21:35:47 +07:00
boboiazumi 534bf0e10a fix: conflict 2025-04-07 21:35:35 +07:00
boboiazumi df479a0a8d fix: success register 2025-04-07 20:48:19 +07:00
boboiazumi 1948d2c67f feat: responsive 2025-04-07 20:40:58 +07:00
79 changed files with 3504 additions and 121 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy Landing
on:
push:
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 511 KiB

@@ -0,0 +1,154 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { FC, useRef } from 'react';
import { motion, useInView, Variants } from 'framer-motion';
export const CTASection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const scaleVariant: Variants = {
hidden: { scale: 0 },
visible: {
scale: 1,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
const contentVariant: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:py-8 lg:px-20">
<motion.div
className="relative max-w-7xl mx-auto bg-white rounded-lg border-2 border-primary-200 py-20 overflow-hidden md:rounded-3xl lg:py-24"
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
{/* Background gradients and shapes */}
<div className="absolute inset-0">
<motion.div
className={cn("absolute top-8 -left-14 size-28 bg-gradient-to-tl from-primary-400 to-primary-300 rounded-full",
"md:size-52 md:-top-20 md:-left-10 lg:size-72 lg:-top-16 lg:-left-14"
)}
variants={scaleVariant}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
/>
<motion.div
className={cn(
"absolute top-24 -right-14 size-[89px] bg-gradient-to-b from-primary-400 to-primary-300 rounded-full blur-[1.8px]",
"md:size-[182px] md:-right-24 md:blur-[2px] lg:size-[392px] lg:top-16 lg:-right-52 lg:blur-[4px]"
)}
variants={scaleVariant}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
/>
<motion.div
variants={scaleVariant}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
className={cn(
"absolute bottom-10 -left-[88px] size-28 bg-primary-200 rounded-full flex justify-center items-center",
"md:size-56 md:-bottom-28 md:-left-28 lg:size-[274px] lg:-bottom-24 lg:-left-24"
)}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: isInView ? 1 : 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="size-[72px] bg-primary-50 rounded-full md:size-40 lg:size-[200px]"
/>
</motion.div>
<div
className={cn(
"absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-primary-400 via-primary-200 opacity-90",
"md:h-[22%] md:opacity-85 lg:h-1/4"
)}
/>
</div>
<motion.img
src="/logos/logo.svg"
alt="IMPHNEN Logo"
className="relative w-28 mx-auto mb-8 md:w-[186px]"
variants={contentVariant}
/>
<div className="relative mb-8 max-w-52 mx-auto md:max-w-[446px] lg:max-w-[688px]">
<motion.h1
className={cn(
"mb-3 text-xl text-center font-semibold leading-tight text-primary-500",
"md:text-4xl md:mb-2 lg:text-5xl lg:font-bold"
)}
variants={contentVariant}
>
Start Your IT Journey Now!
</motion.h1>
<motion.p
className={cn(
"mb-6 text-[15px] font-medium text-center leading-tight text-primary-400",
"md:text-2xl md:mb-8 lg:text-[29px]"
)}
variants={contentVariant}
>
Jangan biarkan progress mu hanya jadi side story!
</motion.p>
<motion.p
className={cn(
"text-xs font-semibold text-center leading-tight text-primary-400",
"md:text-[19px] lg:text-[23px] lg:max-w-[628px] lg:mx-auto"
)}
variants={contentVariant}
>
Dapatkan mentor terbaik dan gunakan AI untuk belajar lebih cepat. Waktunya jadi protagonist dalam perjalanan IT-mu!
</motion.p>
</div>
<motion.div
className="relative max-w-52 mx-auto space-y-3 md:max-w-full md:flex md:gap-x-6 md:justify-center md:space-y-0 md:items-center"
variants={contentVariant}
>
<Button
type="button"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
>
Mulai Belajar Dengan AI
</Button>
<Button
type="button"
variant="secondary"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
>
Temukan Mentor
</Button>
</motion.div>
</motion.div>
</section>
)
}
@@ -0,0 +1,114 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { Accordion, AccordionOptions } from '@imphnen-frontend-service/ui/molecules'
import { FC, useRef } from 'react'
import { motion, useInView, Variants } from 'framer-motion'
import { cn, For } from '@imphnen-frontend-service/utils'
const FAQ_DATA: AccordionOptions[] = [
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.' },
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas untuk mempercepat progres belajarnya.' },
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih dari sekadar kursus-kursus AI guide dan mentor berpengalaman, kamu mendapatkan pengalaman belajar yang jauh lebih personal dan efektif.' },
]
export const FAQSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="relative mx-auto py-4 px-8 overflow-x-clip md:py-8 md:px-[60px] lg:px-20 md:pb-8">
{/* Background shapes */}
<div className="absolute inset-0">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: isInView ? 1 : 0 }}
transition={{ duration: 0.5 }}
className={cn(
"absolute -top-10 -right-14 size-60 bg-primary-200 rounded-full justify-center items-center hidden",
"md:flex lg:size-[344px] lg:top-48 lg:-right-[88px]"
)}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: isInView ? 1 : 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="size-[168px] bg-primary-50 rounded-full lg:size-64"
/>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="relative mb-6 flex justify-center md:mb-10"
>
<Button
type="button"
size="sm"
variant="bordered"
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
>
Frequently Ask Question
</Button>
</motion.div>
<div
className={cn(
"relative grid max-w-7xl mx-auto gap-y-7 divide-y-2 divide-primary-200",
"md:grid-cols-11 md:gap-x-10 md:divide-y-0 md:divide-x-2 lg:grid-cols-7 lg:gap-x-[72px]"
)}
>
<motion.h1
className={cn(
"pb-7 text-2xl font-semibold text-primary-500 text-center leading-none",
"md:pe-10 md:pb-0 md:text-start md:col-span-5 lg:text-4xl lg:col-span-3 lg:pe-[72px]"
)}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
Console log for new developers
</motion.h1>
<motion.div
className="flex flex-col gap-4 md:col-span-6 lg:col-span-4"
variants={containerVariants}
animate={isInView ? "visible" : "hidden"}
>
<For data={FAQ_DATA}>
{(props) => (
<motion.div key={props.title} variants={childVariants}>
<Accordion {...props} />
</motion.div>
)}
</For>
</motion.div>
</div>
</section>
)
}
@@ -0,0 +1,73 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion } from 'framer-motion'
export function HeroSection() {
return (
<section className="relative w-full py-20 md:py-28 lg:py-32">
{/* Background gradients and shapes */}
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="absolute top-10 -left-64 size-[448px] bg-primary-200 rounded-full justify-center items-center hidden lg:flex"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
viewport={{ once: true }}
className="size-[296px] bg-primary-50 rounded-full"
/>
</motion.div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="absolute top-3/5 -right-56 size-96 bg-primary-500 rounded-full flex justify-center items-center lg:top-1/2 lg:-right-64 lg:size-[600px]"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
viewport={{ once: true }}
className="size-64 bg-primary-50 rounded-full lg:size-[400px]"
/>
</motion.div>
<div className="absolute -top-28 -left-20 size-[248px] bg-radial from-primary-300 rounded-full blur-3xl" />
<div className="absolute top-0 -right-72 size-[480px] bg-radial from-primary-400 rounded-full blur-[80px]" />
</div>
<div className="flex flex-col items-center gap-y-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="flex flex-col items-center gap-y-6"
>
<h1 className="text-2xl font-semibold text-primary-500 md:text-4xl md:font-bold lg:text-5xl">DIMENTORIN</h1>
<p className="max-w-52 text-center font-semibold text-xl leading-tight text-primary-500 md:max-w-72 lg:text-4xl lg:max-w-lg">
Learn IT smarter with AI & expert mentors!
</p>
<p className="max-w-3xs text-xs text-neutral-500 text-center font-medium md:max-w-lg md:text-base lg:text-lg lg:max-w-[646px]">
Apakah kamu siap untuk grind skill TI-mu ke level Ultimate? Dengan AI sebagai navigator dan mentor profesional sebagai sensei-mu, perjalanan belajarmu akan lebih cepat, efektif, dan penuh EXP!
</p>
</motion.div>
<motion.div initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} viewport={{ once: true }}>
<Button
type="button"
size="sm"
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
>
Belajar Dengan AI Sekarang
</Button>
</motion.div>
</div>
</section>
)
}
@@ -0,0 +1,113 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { For } from '@imphnen-frontend-service/utils'
import { FC, useRef } from 'react'
import { motion, useInView, Variants } from 'framer-motion'
import { StarFilled } from '@ant-design/icons'
export const TestimonialSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="mx-auto py-4 md:py-8 xl:px-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="flex justify-center"
>
<Button
type="button"
size="sm"
variant="bordered"
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
>
Testimonial
</Button>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="my-6 text-lg font-semibold mx-auto text-center text-primary-500 md:text-2xl lg:my-10 lg:text-4xl"
>
Words from our dellow devs
</motion.h1>
<div className="scrollbar-hide w-full mx-auto max-w-7xl px-8 overflow-auto md:px-[60px] xl:px-0">
<motion.div
className="mb-2 flex gap-x-5 min-w-max w-auto md:gap-x-6 xl:min-w-full xl:max-w-7xl xl:grid xl:grid-cols-2"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={Array.from({ length: 2 })}>
{(_, index) => (
<motion.div
key={index}
variants={childVariants}
className="flex flex-col gap-6 p-6 w-52 bg-white rounded-xl shadow-md md:w-xl md:flex-row md:gap-8 xl:w-full"
>
<div className="w-full aspect-[8/7] rounded-lg overflow-hidden md:w-40 md:aspect-[7/6] lg:aspect-square">
<img
src="/image/testimonial.webp"
alt="Testimonial"
className="w-full object-cover"
/>
</div>
<div className="flex-1">
<h3 className="mb-3 font-semibold text-primary-500 md:text-lg lg:text-2xl">
Riko, Junior Web Developer
</h3>
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!
</p>
<motion.div className="w-max" whileHover={{ scale: 1.1, rotate: -3 }}>
<Button
type="button"
size="sm"
variant="bordered"
className="bg-primary-50 flex items-center gap-x-2 h-auto px-2 py-1 hover:bg-primary-50"
>
<StarFilled className="md:text-lg" />
<span>
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">4.8</span>
<span className="text-[10px] text-primary-300 md:text-sm md:font-medium lg:text-base">/5.0</span>
</span>
</Button>
</motion.div>
</div>
</motion.div>
)}
</For>
</motion.div>
</div>
</section>
)
}
@@ -0,0 +1,84 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { For } from "@imphnen-frontend-service/utils";
import { FC, useRef } from "react";
import { motion, useInView, Variants } from 'framer-motion'
const OFFERS: { title: string; description: string; image: string }[] = [
{ title: "AI-Powered Learning", description: "Level Up Instantly!", image: "/image/what-we-offer/ai-powered-learning.webp" },
{ title: "1-on-1 Mentoring", description: "Sensei dari Dunia Nyata!", image: "/image/what-we-offer/1-on-1-mentoring.webp" },
{ title: "Community & Resources", description: "Connect dengan Fellow Devs!", image: "/image/what-we-offer/community.webp" },
]
export const WhatWeOfferSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="w-full mx-auto px-8 pb-4 md:px-[60px] lg:px-20 lg:pt-4">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="mb-6 flex justify-center lg:mb-10"
>
<Button
type="button"
size="sm"
variant="bordered"
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
>
Apa yang Kami Tawarkan
</Button>
</motion.div>
<motion.div
className="grid gap-6 max-w-7xl mx-auto md:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
<For data={OFFERS}>
{(offer) => (
<motion.div
key={offer.title}
className="relative aspect-[16/10] overflow-hidden rounded-lg md:aspect-[24/35] lg:aspect-[6/7]"
variants={childVariants}
>
<img src={offer.image} alt={offer.title} className="size-full object-cover" />
<div
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
>
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
</div>
</motion.div>
)}
</For>
</motion.div>
</section>
)
}
@@ -0,0 +1,20 @@
import { FC, ReactElement } from 'react';
import { HeroSection } from './_components/hero-section';
import { WhatWeOfferSection } from './_components/what-we-offer-section';
import { TestimonialSection } from './_components/testimonial-section';
import { FAQSection } from './_components/faq-section';
import { CTASection } from './_components/cta-section';
export const Components: FC = (): ReactElement => {
return (
<>
<HeroSection />
<WhatWeOfferSection />
<TestimonialSection />
<FAQSection />
<CTASection />
</>
);
};
export default Components;
@@ -0,0 +1,157 @@
import { DiscordOutlined, GithubOutlined, InstagramOutlined } from '@ant-design/icons';
import { cn, For } from '@imphnen-frontend-service/utils';
import React, { FC, useRef } from 'react';
import { Link } from 'react-router-dom';
import { motion, useInView, Variants } from 'framer-motion';
import { SteamIcon } from '../../_components/icons';
const PAGES: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
]
const COMMUNITY: { label: string; href: string; icon: React.ReactNode }[] = [
{ label: 'Discord', href: 'https://discord.gg/imphnen', icon: <DiscordOutlined /> },
{ label: 'Steam', href: 'https://steamcommunity.com/groups/IMPHNEN', icon: <SteamIcon /> },
{ label: 'Github', href: 'https://github.com/IMPHNEN', icon: <GithubOutlined /> },
{ label: 'Instagram', href: 'https://www.instagram.com/imphnen.dev', icon: <InstagramOutlined /> },
]
export const Footer: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
const mascotVariants: Variants = {
hidden: { opacity: 0, y: 20, rotate: -12 },
visible: {
opacity: 1,
y: 0,
rotate: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
const mascotTextVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
delay: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<footer ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:pt-8 md:pb-16 lg:px-20 lg:pb-20">
<motion.div
className={cn(
"relative max-w-7xl mx-auto px-10 py-8 bg-primary-500 text-primary-50 grid gap-10 rounded-lg",
"lg:grid-cols-3 lg:rounded-xl"
)}
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
{/* Mascot */}
<motion.div
className="hidden absolute bottom-0 right-0 size-40 md:block lg:size-72"
variants={mascotVariants}
>
<motion.img
src="/image/mascot-character.webp"
alt="IMPHNEN Mascot"
className="w-full h-auto absolute inset-x-0 bottom-0"
/>
<motion.img
src="/image/mascot-text.webp"
alt="IMPHNEN Mascot"
className="w-4/6 absolute top-0 left-0"
variants={mascotTextVariants}
/>
</motion.div>
<div className="relative">
<div className="text-center lg:text-start lg:mb-16">
<motion.img
src="/logos/logo.svg"
alt="IMPHNEN Logo"
className="w-[84px] mb-6 mx-auto md:w-32 lg:w-40 lg:ms-0"
variants={childVariants}
/>
<motion.h4 className="mb-2 font-semibold text-[15px] md:mb-2.5 md:text-xl" variants={childVariants}>
Dimentorin by IMPHNEN
</motion.h4>
<motion.p className="font-medium text-xs md:text-[15px] md:max-w-[232px] md:mx-auto lg:ms-0" variants={childVariants}>
Karena Belajar IT Itu Harusnya Se-Seru Anime Favoritmu!
</motion.p>
</div>
<motion.p className="relative hidden text-xs font-medium text-[15px] lg:block" variants={childVariants}>
&copy; 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}>
&copy; IMPHNEN {new Date().getFullYear()} All Rights Reserved
</motion.p>
</motion.div>
</footer>
)
}
@@ -0,0 +1,93 @@
import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
import { FC, useState } from "react";
import { Link, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
]
export const Header: FC = () => {
const location = useLocation();
const { scrollY } = useScroll()
const [expandMenu, setExpandMenu] = useState(false);
const [show, setShow] = useState(true)
const headerVariants: Variants = {
hidden: { opacity: 0, y: -100 },
show: { opacity: 1, y: 0 },
};
useMotionValueEvent(scrollY, "change", (latest) => {
const prev = scrollY.getPrevious() || 0
if (latest > prev && latest > 100) setShow(false)
else setShow(true)
})
return (
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky overflow-hidden">
<motion.header
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
aria-roledescription="nav"
variants={headerVariants}
animate={show ? "show" : "hidden"}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
<div className="flex w-full items-center justify-between p-4 md:px-8 md:py-2.5">
<div className="flex items-center">
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="h-8 md:h-10 lg:h-[52px] w-auto"
/>
</div>
<nav
className={cn(
"flex flex-col items-center py-2 fixed top-24 bg-white shadow-lg rounded-lg transition-all duration-300",
"md:top-32 lg:static lg:py-0 lg:flex-row lg:bg-transparent lg:shadow-none lg:gap-x-4",
!expandMenu ? "-right-96" : "right-8 md:right-[60px]"
)}
>
<For data={MENUS}>
{(menu) => (
<Button
key={menu.label}
type="button"
variant="text"
className={cn(
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
location.pathname === menu.href && "text-primary-500 hover:text-primary-500"
)}
>
<Link to={menu.href}>{menu.label}</Link>
</Button>
)}
</For>
<Button type="button" className="px-12 py-1 md:hidden">Login</Button>
</nav>
<div>
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
<Button
type="button"
variant="text"
className={cn("transition-transform duration-300 rotate-0 lg:hidden", expandMenu && "rotate-90")}
onClick={() => setExpandMenu(prev => !prev)}
>
<Show condition={!expandMenu} fallback={<CloseOutlined />}>
<MenuOutlined />
</Show>
</Button>
</div>
</div>
</motion.header>
</div>
);
}
@@ -0,0 +1,15 @@
import { Outlet } from "react-router-dom";
import { Header } from "./_components/header";
import { Footer } from "./_components/footer";
export default function Layout() {
return (
<div className="flex min-h-screen flex-col bg-primary-50">
<Header />
<main className="flex-1 z-0">
<Outlet />
</main>
<Footer />
</div>
);
}
@@ -0,0 +1 @@
export * from './steam';
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
import { useSendOTP } from '@imphnen-frontend-service/utils';
export const useResendOtpHook = () => {
const { resendOTP, isLoading } = useSendOTP();
return {
resendOTP
};
};
@@ -51,6 +51,11 @@ export const Components: FC = (): ReactElement => {
Daftar Disini Daftar Disini
</a> </a>
</div> </div>
<div className='text-center w-full mb-1'>
<a href="/auth/register-mentor" className="text-primary-500 font-medium">
Daftar Sebagai Mentor
</a>
</div>
<div className="flex items-center w-full"> <div className="flex items-center w-full">
<div className="flex-grow border-t border-blue-400 opacity-50"></div> <div className="flex-grow border-t border-blue-400 opacity-50"></div>
<span className="px-3 text-blue-400">Or</span> <span className="px-3 text-blue-400">Or</span>
@@ -0,0 +1,12 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -0,0 +1,242 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button, Select } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules';
export const Components: FC = (): ReactElement => {
const [ step, setStep ] = useState(1)
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
<div className="bg-white xl:min-w-[1130px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<RegisterResetBanner />
<div className="xl:border-2 xl:border-primary-500/50 xl:w-[726px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="xl:w-[714px]">
<Button
className='xl:hidden gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Login
</Button>
<RegisterMentorStep step={step} />
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
Register
</h3>
<h5 className="text-primary-500 font-medium">
Welcome to the team, Mentor - powered by IMPHNEN
</h5>
<form>
<div className={`${step !== 1 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2'>
<InputField
label="Nama Lengkap"
size="lg"
className="w-full"
placeholder="Nama Lengkap"
name={'fullname'}
/>
</div>
<SelectField
label="Jenis Kelamin"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Pilih Jenis Kelamin
</option>
<option value="laki-laki">Laki - Laki</option>
<option value="perempuan">Perempuan</option>
</SelectField>
<div className='my-2'>
<InputField
label="No Telp/Whatsapp Only"
size="lg"
className="w-full"
placeholder="Nama Lengkap"
name={'fullname'}
/>
</div>
<SelectField
label="Domisili"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Pilih Domisili
</option>
<option value="aceh">Aceh</option>
<option value="jawa-selatan">Jawa Selatan</option>
</SelectField>
<div className='mt-2'>
<SelectField
label="Pendidikan Terakhir"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Apa pendidikan terakhir senpai
</option>
<option value="SMA">SMA</option>
<option value="S1">S1</option>
<option value="S2">S2</option>
<option value="S3">S3</option>
</SelectField>
</div>
</div>
<div className={`${step !== 2 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2 gap-2 flex flex-col'>
<InputField
label="Perusahaan Saat Ini"
size="lg"
className="w-full"
placeholder="Masukkan Perusahaan tempat kerja, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
name={'fullname'}
/>
<InputField
label="Masukan jabatan senpai di perusahaan"
size="lg"
className="w-full"
placeholder="Masukan jabatan senpai di perusahaan"
name={'fullname'}
/>
<SelectField
label="Topik Keahlian Yang Ingin Diajarkan"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Apa yang ingin anda ajarkan senpaii
</option>
<option value="Frontend Developer">Frontend Developer</option>
<option value="Backend Developer">Backend Developer</option>
<option value="Other">Other</option>
</SelectField>
<InputField
label="Jika Mengisi “Other”, Tulis Topik yang Kamu Kuasai"
size="lg"
className="w-full"
placeholder="Tolong isi jika tidak ada di pilihan senpai"
name={'fullname'}
/>
<InputField
label="Skill Utama yang Kamu Miliki (boleh lebih dari satu)"
size="lg"
className="w-full"
placeholder="Isi Skill nya dong senpai"
name={'fullname'}
/>
</div>
</div>
<div className={`${step !== 3 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2 gap-2 flex flex-col'>
<SelectField
label="Durasi Sesi Mentoring"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Placeholder
</option>
</SelectField>
<SelectField
label="Format Pengajaran yang Anda Sediakan"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Placeholder
</option>
</SelectField>
<InputField
type="file"
label="Unggah Curiculum Vitae"
size="lg"
className="w-full"
/>
<InputField
label="Unggah Portofolio"
size="lg"
className="w-full"
placeholder="Google Drive, Behance, GitHub, Notion, dan lainnya"
/>
<InputField
type='number'
label="Honorarium yang Diharapkan per Sesi (Online) Durasi rata-rata sesi: 2,53 jam"
size="lg"
className="w-full"
placeholder="Isi Nominalnya dong senpaiii!!!"
/>
<h6 className='text-xs'>Silahkan isi dalam angka (Contoh:500.000)</h6>
</div>
</div>
<div className={`${step !== 4 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2 gap-2 flex flex-col'>
<InputField
type='email'
label="Email"
size="lg"
className="w-full"
placeholder="isi emailnya dong senpai!!!"
/>
<InputField
type='password'
label="Buat Password"
size="lg"
className="w-full"
placeholder="Buat Password"
/>
<InputField
type='password'
label="Buat Password"
size="lg"
className="w-full"
placeholder="Buat Password"
/>
</div>
</div>
<div className='flex justify-end mt-4 gap-2'>
<Button
variant='secondary'
onClick={() => setStep(step - 1)}
type='button'
className={`${step === 1 ? 'hidden' : ''}`}
>
<ArrowLeftOutlined />
Kembali
</Button>
<Button
variant='primary'
onClick={() => setStep(step + 1)}
type='button'
className={`${step === 4 ? 'hidden' : ''}`}
>
Lanjutkan
<ArrowRightOutlined />
</Button>
<Button
variant='primary'
onClick={() => setStep(step + 1)}
type='button'
className={`${step === 4 ? '' : 'hidden'}`}
>
Selesaikan
<ArrowRightOutlined />
</Button>
</div>
</form>
</div>
</div>
</div>
</div>
);
};
export default Components;
@@ -0,0 +1,12 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -0,0 +1,57 @@
import { FC, ReactElement, useEffect } from 'react';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowRightOutlined } from '@ant-design/icons';
export const Components: FC = (): ReactElement => {
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
<div className="bg-white xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<RegisterResetBanner />
<div className="border-2 border-primary-500/50 xl:w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50">
<div className="xl:w-[704px] flex flex-col justify-center items-center">
<h2 className="text-4xl text-primary-500 font-semibold">
Register Akun Mentor Berhasil
</h2>
<h4 className="mt-3 text-primary-500 font-medium text-xl">
Akun Kamu Telah Berhasil Diverifikasi!
</h4>
<svg
width="198"
height="198"
viewBox="0 0 198 198"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mt-10"
>
<circle cx="99" cy="99" r="99" fill="#BCF8B0" />
<rect
x="31.1152"
y="32.9141"
width="133.971"
height="133.971"
rx="66.9857"
fill="#35BA43"
/>
<path
d="M127.935 75.8846H122.722C121.991 75.8846 121.297 76.2202 120.85 76.7945L90.0997 115.748L75.355 97.0657C75.1319 96.7825 74.8476 96.5535 74.5234 96.3959C74.1992 96.2384 73.8435 96.1563 73.483 96.1558H68.2697C67.77 96.1558 67.4941 96.7301 67.7999 97.1179L88.2277 122.998C89.1824 124.206 91.0171 124.206 91.9792 122.998L128.405 76.8392C128.711 76.4589 128.435 75.8846 127.935 75.8846Z"
fill="#E0FBD8"
/>
</svg>
<h4 className="mt-10 text-primary-500 font-medium text-xl text-center">
Kamu sekarang bisa login dan mulai menggunakan layanan kami. Selamat bergabung! SENPAIIII
</h4>
<a href="/auth/login">
<Button className="gap-3 mt-10" size="lg">
Masuk Jalur Mentor Isekai
<ArrowRightOutlined />
</Button>
</a>
</div>
</div>
</div>
</div>
);
};
export default Components;
@@ -1,12 +1,38 @@
import { FC, ReactElement } from 'react'; import { FC, ReactElement, useEffect, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms"; import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { useOtpHook } from '../../../_hooks/use-otp'; import { useOtpHook } from '../../../_hooks/use-otp';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useResendOtpHook } from '../../../_hooks/use-resend-otp';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useOtpHook() const { form, onSubmit, isLoading } = useOtpHook()
const [ searchParams ] = useSearchParams() const [ searchParams ] = useSearchParams()
const [ disabled, setDisabled] = useState(true);
const [ timeLeft, setTimeLeft ] = useState(5 * 60);
const { resendOTP } = useResendOtpHook()
useEffect(() => {
if (disabled) {
const interval = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(interval);
setDisabled(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [disabled]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
return ( return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'> <div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
@@ -25,7 +51,14 @@ export const Components: FC = (): ReactElement => {
name={'otp'} name={'otp'}
maxLength={6} maxLength={6}
control={form.control} control={form.control}
/> />
<Button type="button" className="mt-2" disabled={disabled} onClick={
() => {
resendOTP({email: searchParams.get("email") || ""})
setDisabled(true)
setTimeLeft(5 * 60)
}
}>Kirim ulang otp {disabled? `(${formatTime(timeLeft)})`: ""}</Button>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button> <Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form> </form>
</div> </div>
+6 -1
View File
@@ -1,7 +1,12 @@
import { Navbar } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react'; import { FC, ReactElement } from 'react';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
return <>Hallo</>; return (
<div className='bg-blue-50'>
<Navbar />
</div>
);
}; };
export default Components; export default Components;
+6
View File
@@ -119,3 +119,9 @@
font-weight: 400; font-weight: 400;
} }
} }
@layer utilities {
.scrollbar-hide::-webkit-scrollbar {
@apply hidden;
}
}
+5 -2
View File
@@ -6,6 +6,7 @@ import {
add404PageToRoutesChildren, add404PageToRoutesChildren,
addErrorElementToRoutes, addErrorElementToRoutes,
convertPagesToRoute, convertPagesToRoute,
ModalLoginProvider,
QueryProvider, QueryProvider,
} from '@imphnen-frontend-service/utils'; } from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
@@ -35,8 +36,10 @@ if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<QueryProvider> <QueryProvider>
<Toaster position="top-right" /> <ModalLoginProvider>
<RouterProvider router={router} /> <Toaster position="top-right" />
<RouterProvider router={router} />
</ModalLoginProvider>
</QueryProvider> </QueryProvider>
</StrictMode> </StrictMode>
); );
+4 -1
View File
@@ -10,10 +10,13 @@ const mappingPublicRoutes = [
'/auth/login', '/auth/login',
'/auth/forgot', '/auth/forgot',
'/auth/forgot/otp', '/auth/forgot/otp',
'/auth/register', '/auth/register',
'/auth/register/otp', '/auth/register/otp',
'/auth/register/success', '/auth/register/success',
'/auth/new-password', '/auth/new-password',
'/auth/register-mentor',
'/auth/register-mentor/pending',
'/auth/register-mentor/success'
]; ];
const mappingRoutePermissions = [ const mappingRoutePermissions = [
+2
View File
@@ -1,8 +1,10 @@
const { composePlugins, withNx } = require('@nx/next'); const { composePlugins, withNx } = require('@nx/next');
const path = require('path');
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */ /** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
const nextConfig = { const nextConfig = {
nx: { svgr: false }, nx: { svgr: false },
output: 'standalone',
}; };
module.exports = composePlugins(withNx)(nextConfig); module.exports = composePlugins(withNx)(nextConfig);
@@ -4,107 +4,170 @@ import { LogoSimple } from '@/app/_components/logo';
import NAVIGATIONS from '@/data/navigations.json'; import NAVIGATIONS from '@/data/navigations.json';
import { Button } from '@components'; import { Button } from '@components';
import { cn } from '@utils'; import { cn } from '@utils';
import { AnimatePresence, motion } from 'framer-motion';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { LuMenu, LuX } from 'react-icons/lu'; import { LuMenu, LuX } from 'react-icons/lu';
export function Header() { export function Header() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const [isScrolled, setIsScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => { useEffect(() => {
const handleScroll = () => { if (mobileMenuOpen) {
setIsScrolled(window.scrollY > 10); document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
}; };
window.addEventListener('scroll', handleScroll); }, [mobileMenuOpen]);
return () => window.removeEventListener('scroll', handleScroll);
}, []); useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
return ( return (
<header <header className="sticky top-0 w-full z-50 bg-background/70">
className={cn( <div className="container flex h-20 items-center justify-between">
'sticky top-0 z-50 w-full transition-all duration-300', <Link
isScrolled href="/"
? 'bg-background/80 backdrop-blur-md border-b shadow-sm' className="relative overflow-hidden rounded z-50"
: 'bg-transparent' aria-label="Home"
)} >
> <LogoSimple className="w-24 md:w-28" />
<div className="container flex h-16 items-center justify-between"> </Link>
<div className="flex items-center gap-2">
<div className="relative overflow-hidden rounded">
<Link href="/">
<LogoSimple className="w-[80px]" />
</Link>
</div>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8"> <nav className="hidden md:flex items-center gap-8">
{NAVIGATIONS.map(({ link, title }) => ( {NAVIGATIONS.map(({ link, title }) => (
<Link <Link
key={link} key={link}
href={link} href={link}
className="text-sm font-medium relative group" className={cn(
'text-sm font-medium relative group px-1 py-2',
pathname === link ? 'text-primary' : 'text-foreground/90'
)}
> >
<span className="transition-colors hover:text-primary"> <span className="transition-colors hover:text-primary block">
{title} {title}
</span> </span>
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary-500 transition-all duration-300 group-hover:w-full"></span> {pathname === link && (
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary-500" />
)}
</Link> </Link>
))} ))}
</nav> </nav>
<div className="flex items-center gap-x-2"> <div className="hidden md:flex items-center gap-x-3">
<Button <Button
onClick={() => router.push('/signin')} onClick={() => router.push('/signin')}
className="hidden md:flex" className="px-5 py-2 text-sm font-medium"
> >
Masuk Masuk
</Button> </Button>
<Button <Button
variant="secondary" variant="bordered"
onClick={() => router.push('/signup')} onClick={() => router.push('/signup')}
className="hidden md:flex" className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
> >
Daftar Daftar
</Button> </Button>
{/* Mobile Menu Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="flex md:hidden"
>
{mobileMenuOpen ? (
<LuX className="size-5" />
) : (
<LuMenu className="size-5" />
)}
</button>
</div> </div>
</div>
{/* Mobile Menu */} <button
{mobileMenuOpen && ( onClick={() => setMobileMenuOpen((prev) => !prev)}
<div className="md:hidden border-t bg-background/95 backdrop-blur-md"> className="flex md:hidden relative z-50 p-2 rounded-lg hover:bg-muted transition-colors"
<nav className="container flex flex-col py-4 text-center"> aria-label={mobileMenuOpen ? 'Tutup menu' : 'Buka menu'}
{NAVIGATIONS.map(({ link, title }) => ( >
<Link {mobileMenuOpen ? (
key={link} <LuX className="size-6" />
href={link} ) : (
className="py-3 text-sm font-medium border-b border-border/50" <LuMenu className="size-6" />
onClick={() => router.push(link)} )}
</button>
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-60 bg-background flex flex-col"
>
<div className="container flex h-20 items-center justify-between">
<Link
href="/"
className="relative overflow-hidden rounded"
onClick={() => setMobileMenuOpen(false)}
>
<LogoSimple className="w-24" />
</Link>
<button
onClick={() => setMobileMenuOpen(false)}
className="p-2 rounded-lg hover:bg-muted transition-colors"
aria-label="Tutup menu"
>
<LuX className="size-6" />
</button>
</div>
<motion.nav
className="flex-1 flex flex-col items-center justify-center gap-6 py-10"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
> >
{title} {NAVIGATIONS.map(({ link, title }) => (
</Link> <Link
))} key={link}
href={link}
className={cn(
'text-2xl font-medium py-2 px-6 rounded-lg transition-colors',
pathname === link
? 'text-primary bg-primary/10'
: 'text-foreground hover:bg-muted'
)}
onClick={() => setMobileMenuOpen(false)}
>
{title}
</Link>
))}
</motion.nav>
<Button onClick={() => router.push('/signin')}>Login</Button> <motion.div
</nav> className="container space-y-4 pb-10"
</div> initial={{ y: 20, opacity: 0 }}
)} animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Button
onClick={() => {
setMobileMenuOpen(false);
router.push('/signin');
}}
className="w-full py-4 text-base"
>
Masuk
</Button>
<Button
variant="bordered"
onClick={() => {
setMobileMenuOpen(false);
router.push('/signup');
}}
className="w-full py-4 text-base shadow-lg shadow-primary/20"
>
Daftar
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</header> </header>
); );
} }
@@ -1,8 +1,9 @@
'use client'; 'use client';
import { Button, MoonIcon, SunIcon } from '@components'; import { Button } from '@components';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { LuMoon, LuSun } from 'react-icons/lu';
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -28,9 +29,9 @@ export function ThemeToggle() {
className="focus-visible:ring-0 cursor-pointer" className="focus-visible:ring-0 cursor-pointer"
> >
{theme === 'dark' ? ( {theme === 'dark' ? (
<SunIcon className="h-[1.2rem] w-[1.2rem]" /> <LuSun className="h-[1.2rem] w-[1.2rem]" />
) : ( ) : (
<MoonIcon className="h-[1.2rem] w-[1.2rem]" /> <LuMoon className="h-[1.2rem] w-[1.2rem]" />
)} )}
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
@@ -0,0 +1,35 @@
'use client';
import { motion } from 'framer-motion';
import { RequestFeaturePopup } from './request-feature-popup';
export function FeatureRequestCTA() {
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="bg-primary-500 rounded-xl px-4 py-8 md:p-8 mb-8 text-center mx-auto max-w-7xl"
>
<div className="max-w-3xl mx-auto">
<motion.h1
className="text-2xl md:text-3xl font-bold text-white mb-3"
initial={{ scale: 0.95 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
Punya Ide Bagus untuk IMPHNEN?
</motion.h1>
<motion.p
className="text-white/90 mb-6 text-lg text-balance"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
Bagikan ide fitur yang kamu inginkan dan vote untuk membuatnya nyata!
</motion.p>
<RequestFeaturePopup />
</div>
</motion.div>
);
}
@@ -0,0 +1,239 @@
'use client';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@components';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { BiUpvote } from 'react-icons/bi';
import { FiCheckCircle } from 'react-icons/fi';
import { MdOutlineOpenInNew } from 'react-icons/md';
export default function ProjectsVote() {
const [upcomingItems, setUpcomingItems] = useState([
{
title: 'IMPHNEN Project Showcase',
description: 'Showcase projectmu ke member lain dan dapatkan feedback',
votes: 42,
voted: false,
},
{
title: 'IMPHNEN Meme Generator',
description: 'Bikin meme kocak kapanpun dengan mudah',
votes: 42,
voted: false,
},
]);
const inProgressItems = [
{
title: 'IMPHNEN Twibbon',
description: 'Buat Twibbon kece untuk profile media sosialmu',
},
{
title: 'IMPHNEN Certificate',
description: 'Cetak sertifikat keren secara instan untuk anggota IMPHNEN',
},
];
const completedItems = [
{
title: 'IMPHNEN List Event',
description:
'Koleksi daftar event dan kolaborasi seru yang bisa kamu ikuti',
},
{
title: 'IMPHNEN Testimoni',
description: 'Berikan testimonial gokil buat komunitas IMPHNEN',
},
{
title: 'IMPHNEN Roadmap by Vote',
description: 'Usulkan ide fitur seru dan ajak anggota lain buat voting',
},
];
const handleVote = (index: number) => {
const newItems = [...upcomingItems];
newItems[index] = {
...newItems[index],
votes: newItems[index].voted
? newItems[index].votes - 1
: newItems[index].votes + 1,
voted: !newItems[index].voted,
};
setUpcomingItems(newItems);
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2 } },
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-7xl mx-auto">
{/* Vote Now Column */}
<div className="space-y-6">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center justify-center w-10 h-10 bg-amber-100 text-amber-600 rounded-lg">
🔥
</div>
<h2 className="text-xl font-semibold text-gray-900">Vote Now</h2>
</div>
<AnimatePresence>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-5"
>
{upcomingItems.map((item, index) => (
<motion.div key={index} variants={itemVariants} layout>
<Card className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all h-full flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
{item.title}
</CardTitle>
</CardHeader>
<CardContent className="flex-grow">
<p className="text-gray-600 text-sm mb-4">
{item.description}
</p>
<div className="flex items-center justify-between border-t border-gray-100 pt-3">
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => handleVote(index)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
item.voted
? 'bg-primary-500 text-white hover:bg-primary-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<motion.span
animate={{ scale: item.voted ? [1, 1.2, 1] : 1 }}
transition={{ duration: 0.2 }}
>
<BiUpvote
className={`w-4 h-4 ${
item.voted ? 'text-white' : 'text-gray-600'
}`}
/>
</motion.span>
<span>{item.voted ? 'Voted' : 'Vote'}</span>
</motion.button>
<div className="flex items-center gap-2 bg-gray-50 px-3 py-1.5 rounded-lg">
<BiUpvote className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
{item.votes}
</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
{/* In Progress Column */}
<div className="space-y-6">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center justify-center w-10 h-10 bg-primary-100 text-primary-600 rounded-lg">
🧑🔧
</div>
<h2 className="text-xl font-semibold text-gray-900">In Progress</h2>
</div>
<AnimatePresence>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-5"
>
{inProgressItems.map((item, index) => (
<motion.div key={index} variants={itemVariants} layout>
<Card className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all h-full">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
{item.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm">{item.description}</p>
<div className="mt-4 pt-3 border-t border-gray-100">
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-primary-500 h-1.5 rounded-full"
style={{ width: `${(index + 1) * 33}%` }}
></div>
</div>
<p className="text-xs text-gray-500 mt-2">
{index === 0 ? 'Development started' : 'In development'}
</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
{/* Completed Column */}
<div className="space-y-6">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center justify-center w-10 h-10 bg-green-100 text-green-600 rounded-lg">
🤓
</div>
<h2 className="text-xl font-semibold text-gray-900">Completed</h2>
</div>
<AnimatePresence>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-5"
>
{completedItems.map((item, index) => (
<motion.div key={index} variants={itemVariants} layout>
<Card className="bg-white border border-gray-200 hover:border-green-200 shadow-sm hover:shadow-md transition-all h-full">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
{item.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm mb-3">
{item.description}
</p>
<div className="flex justify-between">
<div className="flex items-center gap-2 text-green-600">
<FiCheckCircle className="w-4 h-4" />
<span className="text-sm font-medium">Implemented</span>
</div>
<Button size="sm" className="flex gap-x-2">
Coba sekarang
<MdOutlineOpenInNew className="size-4" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
'use client';
import * as React from 'react';
import { useMediaQuery } from '@/hooks/use-media-query';
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Input,
Label,
Textarea,
} from '@components';
import { cn } from '@utils';
export function RequestFeaturePopup() {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery('(min-width: 768px)');
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="secondary">Request Fitur</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-2xl">Request Fitur</DialogTitle>
</DialogHeader>
<ProfileForm />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="secondary">Request Fitur</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Request Fitur</DrawerTitle>
</DrawerHeader>
<ProfileForm className="px-4" />
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="bordered">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
interface ProfileFormProps extends React.ComponentProps<'form'> {
showFooterButton?: boolean;
}
function ProfileForm({
className,
showFooterButton = false,
}: ProfileFormProps) {
const [description, setDescription] = React.useState('');
const maxDescriptionLength = 10000;
return (
<form className={cn('grid items-start gap-8', className)}>
<div className="grid gap-4">
<Label htmlFor="namaFitur" className="font-medium">
Nama Fitur *
</Label>
<Input
id="namaFitur"
placeholder="Masukkan nama fitur yang diinginkan"
className="h-11"
/>
</div>
<div className="grid gap-4">
<div className="flex justify-between items-center">
<Label htmlFor="deskripsiFitur" className="font-medium">
Deskripsi Fitur *
</Label>
<span className="text-sm text-muted-foreground">
{description.length}/{maxDescriptionLength}
</span>
</div>
<Textarea
id="deskripsiFitur"
placeholder="Jelaskan fitur yang Anda inginkan secara detail"
maxLength={maxDescriptionLength}
rows={6}
className="min-h-[150px]"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-sm text-muted-foreground -mt-2">
Jelaskan manfaat dan cara kerja fitur yang Anda inginkan
</p>
</div>
{!showFooterButton && (
<Button type="submit" size="lg" className="mt-2">
Kirim Permintaan
</Button>
)}
</form>
);
}
@@ -0,0 +1,11 @@
import { FeatureRequestCTA } from './_components/feature-request-cta';
import ProjectsVote from './_components/projects-vote';
export default function Page() {
return (
<div className="pt-4 md:pt-6 pb-56 bg-gray-50 min-h-screen mx-4 lg:mx-0">
<FeatureRequestCTA />
<ProjectsVote />
</div>
);
}
@@ -1,3 +0,0 @@
export default function Page() {
return <></>;
}
+3 -3
View File
@@ -13,10 +13,10 @@
}, },
{ {
"title": "Roadmap", "title": "Roadmap",
"link": "/roadmaps" "link": "/roadmap"
}, },
{ {
"title": "Artikel", "title": "Team",
"link": "/articles" "link": "/teams"
} }
] ]
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
// 1) Always default to `false`—this guarantees SSR and the very first client render match.
const [matches, setMatches] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQueryList = window.matchMedia(query);
// 2) On mount, immediately set to the real match value (true or false).
setMatches(mediaQueryList.matches);
// 3) Subscribe to changes.
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQueryList.addEventListener('change', listener);
return () => {
mediaQueryList.removeEventListener('change', listener);
};
}, [query]);
return matches;
}
export default useMediaQuery;
+10
View File
@@ -0,0 +1,10 @@
version: '3.8'
services:
landing:
build:
context: .
dockerfile: docker/landing.Dockerfile
ports:
- "3000:3000"
restart: unless-stopped
container_name: landing
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run backoffice:build
FROM nginx:alpine AS runner
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist/apps/backoffice /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run dimentorin:build
FROM nginx:alpine AS runner
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist/apps/dimentorin /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run gacha:build
FROM nginx:alpine AS runner
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist/apps/gacha /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install
RUN npm run landing:build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist/apps/landing/.next/standalone .
COPY --from=builder /app/dist/apps/landing/public apps/landing/public
COPY --from=builder /app/dist/apps/landing/.next/static dist/apps/landing/.next/static
EXPOSE 3000
ENTRYPOINT ["node", "apps/landing/server.js"]

Some files were not shown because too many files have changed in this diff Show More