summaryrefslogtreecommitdiff
path: root/components/login
diff options
context:
space:
mode:
Diffstat (limited to 'components/login')
-rw-r--r--components/login/login-form-skeleton.tsx71
-rw-r--r--components/login/login-form.tsx323
-rw-r--r--components/login/partner-auth-form.tsx241
3 files changed, 635 insertions, 0 deletions
diff --git a/components/login/login-form-skeleton.tsx b/components/login/login-form-skeleton.tsx
new file mode 100644
index 00000000..c434c4b7
--- /dev/null
+++ b/components/login/login-form-skeleton.tsx
@@ -0,0 +1,71 @@
+import { Skeleton } from "@/components/ui/skeleton"
+import { Button } from "@/components/ui/button"
+import { Ship, Loader2, GlobeIcon, ChevronDownIcon } from "lucide-react"
+import Link from "next/link"
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+export function LoginFormSkeleton() {
+ return (
+ <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
+ {/* Left Content */}
+ <div className="flex flex-col w-full h-screen lg:p-2">
+ {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Ship className="w-4 h-4" />
+ <span className="text-md font-bold">eVCP</span>
+ </div>
+ <Link
+ href="/partners/repository"
+ className={cn(buttonVariants({ variant: "ghost" }))}
+ >
+ Request Vendor Repository
+ </Link>
+ </div>
+
+ {/* Content section that occupies remaining space, centered vertically */}
+ <div className="flex-1 flex items-center justify-center">
+ {/* Form container */}
+ <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]">
+ <div className="p-6 md:p-8">
+ <div className="flex flex-col gap-6">
+ <div className="flex flex-col items-center text-center">
+ <Skeleton className="h-8 w-48 mb-2" />
+ </div>
+ <div className="grid gap-2">
+ <Skeleton className="h-10 w-full" />
+ </div>
+ <Skeleton className="h-10 w-full" />
+ <div className="text-center text-sm mx-auto">
+ <Button variant="ghost" className="flex items-center gap-2" disabled>
+ <GlobeIcon className="h-4 w-4" />
+ <Skeleton className="h-4 w-16" />
+ <ChevronDownIcon className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ <div className="text-balance text-center">
+ <Skeleton className="h-4 w-[280px] mx-auto" />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Right BG 이미지 영역 */}
+ <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex">
+ <div className="absolute inset-0 bg-zinc-100 animate-pulse" />
+ <div className="relative z-20 flex items-center text-lg font-medium">
+ {/* Optional top-right content on the image side */}
+ </div>
+ <div className="relative z-20 mt-auto">
+ <blockquote className="space-y-2">
+ <Skeleton className="h-4 w-[250px] bg-white/50" />
+ </blockquote>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx
new file mode 100644
index 00000000..41d232f8
--- /dev/null
+++ b/components/login/login-form.tsx
@@ -0,0 +1,323 @@
+'use client';
+
+import { useState, useEffect } from "react";
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu"
+import { useTranslation } from '@/i18n/client'
+import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation';
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp"
+import { signIn } from 'next-auth/react';
+import { sendOtpAction } from "@/lib/users/send-otp";
+import { verifyTokenAction } from "@/lib/users/verifyToken";
+import { buttonVariants } from "@/components/ui/button"
+import Link from "next/link"
+import Image from 'next/image'; // 추가: Image 컴포넌트 import
+
+export function LoginForm({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+
+ const params = useParams() || {};
+ const pathname = usePathname() || '';
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const token = searchParams?.get('token') || null;
+
+
+ const lng = params.lng as string;
+ const { t, i18n } = useTranslation(lng, 'login');
+
+ const { toast } = useToast();
+
+ const handleChangeLanguage = (lang: string) => {
+ const segments = pathname.split('/');
+ segments[1] = lang;
+ router.push(segments.join('/'));
+ };
+
+ const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english');
+
+ const [email, setEmail] = useState('');
+ const [otpSent, setOtpSent] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [otp, setOtp] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+ try {
+ const result = await sendOtpAction(email, lng);
+
+ if (result?.success) {
+ setOtpSent(true);
+ toast({
+ title: t('otpSentTitle'),
+ description: t('otpSentMessage'),
+ });
+ }
+ } catch (error) {
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+
+ async function handleOtpSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setIsLoading(true);
+
+ try {
+ // next-auth의 Credentials Provider로 로그인 시도
+ const result = await signIn('credentials', {
+ email,
+ code: otp,
+ redirect: false, // 커스텀 처리 위해 redirect: false
+ });
+
+ if (result?.ok) {
+ // 토스트 메시지 표시
+ toast({
+ title: t('loginSuccess'),
+ description: t('youAreLoggedIn'),
+ });
+
+ // NextAuth에서 유저 정보 API 호출 (최신 상태 보장)
+ const response = await fetch('/api/auth/session');
+ const session = await response.json();
+
+ // domain 값에 따라 동적으로 리다이렉션
+ const userDomain = session?.user?.domain;
+ console.log(session)
+
+ if (userDomain === 'evcp') {
+ router.push(`/${lng}/evcp/report`);
+ } else if (userDomain === 'partners') {
+ router.push(`/${lng}/partners/dashboard`);
+ } else {
+ // 기본 리다이렉션 경로
+ router.push(`/${lng}/dashboard`);
+ }
+ } else {
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ }
+ } catch (error) {
+ console.error('Login error:', error);
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ const verifyToken = async () => {
+ if (!token) return;
+ setIsLoading(true);
+
+ try {
+ const data = await verifyTokenAction(token);
+
+ if (data.valid) {
+ setOtpSent(true);
+ setEmail(data.email ?? '');
+ } else {
+ toast({
+ title: t('errorTitle'),
+ description: t('invalidToken'),
+ variant: 'destructive',
+ });
+ }
+ } catch (error) {
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ verifyToken();
+ }, [token, toast, t]);
+
+ return (
+ <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
+ {/* Left Content */}
+ <div className="flex flex-col w-full h-screen lg:p-2">
+ {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ {/* <img
+ src="/images/logo.png"
+ alt="logo"
+ className="h-8 w-auto"
+ /> */}
+ <Ship className="w-4 h-4" />
+ <span className="text-md font-bold">eVCP</span>
+ </div>
+ <Link
+ href="/partners/repository"
+ className={cn(buttonVariants({ variant: "ghost" }))}
+ >
+ Request Vendor Repository
+ </Link>
+ </div>
+
+ {/* Content section that occupies remaining space, centered vertically */}
+ <div className="flex-1 flex items-center justify-center">
+ {/* Your form container */}
+ <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]">
+
+ {/* Here's your existing login/OTP forms: */}
+ {!otpSent ? (
+ <form onSubmit={handleSubmit} className="p-6 md:p-8">
+ <div className="flex flex-col gap-6">
+ <div className="flex flex-col items-center text-center">
+ <h1 className="text-2xl font-bold">{t('loginMessage')}</h1>
+ </div>
+ <div className="grid gap-2">
+ <Input
+ id="email"
+ type="email"
+ placeholder={t('email')}
+ required
+ className="h-10"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ />
+ </div>
+ <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}>
+ {isLoading ? t('sending') : t('ContinueWithEmail')}
+ </Button>
+ <div className="text-center text-sm mx-auto">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="flex items-center gap-2">
+ <GlobeIcon className="h-4 w-4" />
+ <span>{currentLanguageText}</span>
+ <ChevronDownIcon className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuRadioGroup
+ value={i18n.language}
+ onValueChange={(value) => handleChangeLanguage(value)}
+ >
+ <DropdownMenuRadioItem value="en">
+ {t('languages.english')}
+ </DropdownMenuRadioItem>
+ <DropdownMenuRadioItem value="ko">
+ {t('languages.korean')}
+ </DropdownMenuRadioItem>
+ </DropdownMenuRadioGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </form>
+ ) : (
+ <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8">
+ <div className="flex flex-col gap-6">
+ <div className="flex flex-col items-center text-center">
+ <h1 className="text-2xl font-bold">{t('loginMessage')}</h1>
+ </div>
+ <div className="grid gap-2 justify-center">
+ <InputOTP
+ maxLength={6}
+ value={otp}
+ onChange={(value) => setOtp(value)}
+ >
+ <InputOTPGroup>
+ <InputOTPSlot index={0} />
+ <InputOTPSlot index={1} />
+ <InputOTPSlot index={2} />
+ <InputOTPSlot index={3} />
+ <InputOTPSlot index={4} />
+ <InputOTPSlot index={5} />
+ </InputOTPGroup>
+ </InputOTP>
+ </div>
+ <Button type="submit" className="w-full" disabled={isLoading}>
+ {isLoading ? t('verifying') : t('verifyOtp')}
+ </Button>
+ <div className="mx-auto">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="flex items-center gap-2">
+ <GlobeIcon className="h-4 w-4" />
+ <span>{currentLanguageText}</span>
+ <ChevronDownIcon className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuRadioGroup
+ value={i18n.language}
+ onValueChange={(value) => handleChangeLanguage(value)}
+ >
+ <DropdownMenuRadioItem value="en">
+ {t('languages.english')}
+ </DropdownMenuRadioItem>
+ <DropdownMenuRadioItem value="ko">
+ {t('languages.korean')}
+ </DropdownMenuRadioItem>
+ </DropdownMenuRadioGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </form>
+ )}
+
+ <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
+ {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')}
+ <a href="#">{t('privacyPolicy')}</a>.
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */}
+ <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex">
+ {/* Image 컴포넌트로 대체 */}
+ <div className="absolute inset-0">
+ <Image
+ src="/images/02.jpg"
+ alt="Background image"
+ fill
+ priority
+ sizes="(max-width: 1024px) 100vw, 50vw"
+ className="object-cover"
+ />
+ </div>
+ <div className="relative z-10 mt-auto">
+ <blockquote className="space-y-2">
+ <p className="text-sm">&ldquo;{t("blockquote")}&rdquo;</p>
+ {/* <footer className="text-sm">SHI</footer> */}
+ </blockquote>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx
new file mode 100644
index 00000000..effd7bd3
--- /dev/null
+++ b/components/login/partner-auth-form.tsx
@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import { useToast } from "@/hooks/use-toast"
+import { useRouter, useParams, usePathname } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
+import Link from "next/link"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { GlobeIcon, ChevronDownIcon, Loader, Ship } from "lucide-react"
+import { languages } from "@/config/language"
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+import { siteConfig } from "@/config/site"
+
+import { checkJoinPortal } from "@/lib/vendors/service"
+import Image from "next/image"
+// ↑ 실제 경로 맞춤 수정 (ex: "@/app/[lng]/actions/joinPortal" 등)
+
+interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> { }
+
+export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) {
+ const [isLoading, setIsLoading] = React.useState<boolean>(false)
+ const router = useRouter()
+ const { toast } = useToast()
+ const params = useParams()
+ const pathname = usePathname()
+
+ const lng = params.lng as string
+ const { t, i18n } = useTranslation(lng, "login")
+
+ const handleChangeLanguage = (lang: string) => {
+ const segments = pathname.split("/")
+ segments[1] = lang
+ router.push(segments.join("/"))
+ }
+
+ const currentLanguageText =
+ i18n.language === "ko"
+ ? t("languages.korean")
+ : i18n.language === "ja"
+ ? t("languages.japanese")
+ : t("languages.english")
+
+ // ---------------------------
+ // 1) onSubmit -> 서버 액션 호출
+ // ---------------------------
+ async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
+ event.preventDefault()
+ setIsLoading(true)
+
+ const formData = new FormData(event.currentTarget)
+ const taxID = formData.get("taxid")?.toString().trim()
+
+ if (!taxID) {
+ toast({
+ variant: "destructive",
+ title: "오류",
+ description: "Tax ID를 입력해주세요.",
+ })
+ setIsLoading(false)
+ return
+ }
+
+ try {
+ // ---------------------------
+ // 2) 서버 액션 호출
+ // ---------------------------
+ const result = await checkJoinPortal(taxID)
+
+ if (result.success) {
+ toast({
+ variant: "default",
+ title: "성공",
+ description: "가입 신청이 가능합니다",
+ })
+ // 가입 가능 → signup 페이지 이동
+ router.push(`/partners/signup?taxID=${taxID}`)
+ } else {
+ toast({
+ variant: "destructive",
+ title: "가입이 진행 중이거나 완료된 회사",
+ description: `${result.data} 에 연락하여 계정 생성 요청을 하시기 바랍니다.`,
+ })
+ }
+ } catch (error: any) {
+ console.error(error)
+ toast({
+ variant: "destructive",
+ title: "오류",
+ description: "서버 액션 호출에 실패했습니다. 잠시 후 다시 시도해주세요.",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
+
+ {/* Left BG 이미지 영역 */}
+
+ <div className="flex flex-col w-full h-screen lg:p-2">
+ {/* Top bar */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ {/* <img
+ src="/images/logo.png"
+ alt="logo"
+ className="h-8 w-auto"
+ /> */}
+ <Ship className="w-4 h-4" />
+ <span className="text-md font-bold">eVCP</span>
+ </div>
+
+ {/* Remove 'absolute right-4 top-4 ...', just use buttonVariants */}
+ <Link
+ href="/login"
+ className={cn(
+ buttonVariants({ variant: "ghost" })
+ )}
+ >
+ Login
+ </Link>
+ </div>
+ <div className="flex-1 flex items-center justify-center">
+ <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]">
+ <div className="flex flex-col space-y-2 text-center">
+ <h1 className="text-2xl font-semibold tracking-tight">
+ {t("heading")}
+ </h1>
+ <p className="text-sm text-muted-foreground">{t("subheading")}</p>
+ </div>
+
+ <div className={cn("grid gap-6", className)} {...props}>
+ <form onSubmit={onSubmit}>
+ <div className="grid gap-2">
+ <div className="grid gap-1">
+ <label className="sr-only" htmlFor="taxid">
+ Business Number / Tax ID
+ </label>
+ <input
+ id="taxid"
+ name="taxid"
+ placeholder="880-81-01710"
+ type="text"
+ autoCapitalize="none"
+ autoComplete="off"
+ autoCorrect="off"
+ disabled={isLoading}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
+ />
+ </div>
+ <Button type="submit" disabled={isLoading} variant="samsung">
+ {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {t("joinButton")}
+ </Button>
+
+ {/* 언어 선택 Dropdown */}
+ <div className="mx-auto">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="flex items-center gap-2">
+ <GlobeIcon className="h-4 w-4" />
+ <span>{currentLanguageText}</span>
+ <ChevronDownIcon className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuRadioGroup
+ value={i18n.language}
+ onValueChange={(value) => handleChangeLanguage(value)}
+ >
+ {languages.map((v) => (
+ <DropdownMenuRadioItem key={v.value} value={v.value}>
+ {t(v.labelKey)}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </form>
+ </div>
+ <p className="px-8 text-center text-sm text-muted-foreground">
+ {t("agreement")}{" "}
+ <Link
+ href="/terms"
+ className="underline underline-offset-4 hover:text-primary"
+ >
+ {t("termsOfService")}
+ </Link>{" "}
+ {t("and")}{" "}
+ <Link
+ href="/privacy"
+ className="underline underline-offset-4 hover:text-primary"
+ >
+ {t("privacyPolicy")}
+ </Link>
+ .
+ </p>
+ </div>
+ </div>
+
+ </div>
+
+
+ {/* Right Content */}
+ <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex">
+ {/* Image 컴포넌트로 대체 */}
+ <div className="absolute inset-0">
+ <Image
+ src="/images/02.jpg"
+ alt="Background image"
+ fill
+ priority
+ sizes="(max-width: 1024px) 100vw, 50vw"
+ className="object-cover"
+ />
+ </div>
+ <div className="relative z-10 mt-auto">
+ <blockquote className="space-y-2">
+ <p className="text-sm">&ldquo;{t("blockquote")}&rdquo;</p>
+ {/* <footer className="text-sm">SHI</footer> */}
+ </blockquote>
+ </div>
+ </div>
+
+ </div>
+ )
+} \ No newline at end of file