Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions apps/web/app/(auth)/login/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { signIn, getSession } from 'next-auth/react';
Expand All @@ -11,6 +11,16 @@ import { toast } from 'sonner';
import SocialLoginButtons from '@/app/components/SocialLoginButtons';
import type { OAuthProvider } from '@/lib/auth-providers';

const OAUTH_ERROR_MESSAGES: Record<string, string> = {
OAuthAccountNotLinked: 'This email is already registered. Sign in with email & password, or use the same social login you originally used.',
OAuthSignin: 'Could not start social login. Please try again.',
OAuthCallback: 'Social login failed during callback. Please try again.',
OAuthCreateAccount: 'Could not create account via social login. Please try again.',
Configuration: 'A server configuration error occurred. Please try again later.',
AccessDenied: 'Access was denied. Please try again.',
Verification: 'The sign-in link has expired or already been used.',
};

const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(1, 'Password is required'),
Expand All @@ -21,8 +31,17 @@ export default function LoginForm({ enabledProviders }: { enabledProviders: OAut
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl');
const oauthError = searchParams.get('error');
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
if (oauthError) {
const message = OAUTH_ERROR_MESSAGES[oauthError] ?? `Sign-in error: ${oauthError}`;
toast.error(message, { duration: 6000 });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const {
register,
handleSubmit,
Expand Down Expand Up @@ -130,17 +149,6 @@ export default function LoginForm({ enabledProviders }: { enabledProviders: OAut
Don&apos;t have an account?{' '}
<Link href="/register">Create one — it&apos;s free</Link>
</p>

{/* Demo credentials (dev only) */}
{process.env.NODE_ENV === 'development' && (
<>
<div className="auth-divider"><span>Demo credentials</span></div>
<div className="auth-demo-creds">
<p><strong>Customer:</strong> sarah.miller@gmail.com / Customer2026!</p>
<p><strong>Admin:</strong> admin@ecowoods.ca / Admin2026!</p>
</div>
</>
)}
</div>
</div>
);
Expand Down
82 changes: 76 additions & 6 deletions apps/web/app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';

/* ---------------------- Hooks ---------------------- */
function useScrollState() {
Expand Down Expand Up @@ -42,15 +43,35 @@ const navigation = [
{ label: 'FAQ', href: '#faq' },
];

const MYPAGE_NAV = [
{ href: '/mypage', label: 'Dashboard' },
{ href: '/mypage/quotes', label: 'My Quotes' },
{ href: '/mypage/projects', label: 'My Projects' },
{ href: '/mypage/invoices', label: 'Invoices & Payments' },
{ href: '/mypage/inquiries', label: 'Inquiries' },
];

const ADMIN_NAV = [
{ href: '/admin', label: 'Dashboard' },
{ href: '/admin/quotes', label: 'Quotes & Leads' },
{ href: '/admin/projects', label: 'Projects' },
{ href: '/admin/invoices', label: 'Invoices' },
{ href: '/admin/users', label: 'Customers' },
{ href: '/admin/inquiries', label: 'Inquiries' },
{ href: '/admin/settings', label: 'Settings' },
];

const PHONE_DISPLAY = '(416) 249-1276';
const PHONE_HREF = 'tel:+14162491276';

/* ---------------------- Component ---------------------- */
export default function Header() {
const { direction, scrolled } = useScrollState();
const [mobileOpen, setMobileOpen] = useState(false);
const [portalExpanded, setPortalExpanded] = useState(false);
const [activeSection, setActiveSection] = useState<string>('');
const [baseUrl, setBaseUrl] = useState('/');
const { data: session, status } = useSession();

useEffect(() => {
setBaseUrl(window.location.origin + '/');
Expand All @@ -70,6 +91,11 @@ export default function Header() {
};
}, [mobileOpen]);

// Reset portal sub-menu when mobile sheet closes
useEffect(() => {
if (!mobileOpen) setPortalExpanded(false);
}, [mobileOpen]);

// Track active section on scroll
useEffect(() => {
const onScroll = () => {
Expand Down Expand Up @@ -99,6 +125,12 @@ export default function Header() {
return () => window.removeEventListener('keydown', onKey);
}, []);

const isAdmin = session?.user?.role === 'ADMIN';
const isLoggedIn = status === 'authenticated';
const portalLabel = isAdmin ? 'Admin' : 'My Page';
const portalNav = isAdmin ? ADMIN_NAV : MYPAGE_NAV;
const portalIndex = navigation.length + 2;

return (
<>
<header
Expand Down Expand Up @@ -145,12 +177,12 @@ export default function Header() {

{/* Right CTA cluster */}
<div className="topbar-cta">
<a className="login-btn" href="/login" aria-label="Login to your account">
<a className="login-btn" href={isLoggedIn ? (isAdmin ? '/admin' : '/mypage') : '/login'} aria-label={isLoggedIn ? portalLabel : 'Login to your account'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" width="15" height="15">
<circle cx="12" cy="8" r="3.5" strokeLinecap="round" />
<path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Login
{isLoggedIn ? portalLabel : 'Login'}
</a>
<a className="phone-pill" href={PHONE_HREF} aria-label={`Call ${PHONE_DISPLAY}`}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
Expand Down Expand Up @@ -214,10 +246,48 @@ export default function Header() {
Free Quote
<span className="num">0{navigation.length + 1}</span>
</a>
<a href="/login" onClick={() => setMobileOpen(false)}>
Login
<span className="num">0{navigation.length + 2}</span>
</a>

{/* Portal section — Login or My Page / Admin */}
{!isLoggedIn && status !== 'loading' ? (
<a href="/login" onClick={() => setMobileOpen(false)}>
Login
<span className="num">0{portalIndex}</span>
</a>
) : isLoggedIn ? (
<div className="mobile-portal-section">
<button
className="mobile-portal-trigger"
onClick={() => setPortalExpanded((v) => !v)}
aria-expanded={portalExpanded}
>
<span>{portalLabel}</span>
<span className="mobile-portal-right">
<span
className="mobile-portal-arrow"
style={{ transform: portalExpanded ? 'rotate(180deg)' : 'none' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
<span className="num" style={{ position: 'static' }}>0{portalIndex}</span>
</span>
</button>
{portalExpanded && (
<div className="mobile-portal-submenu">
{portalNav.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
>
{item.label}
</a>
))}
</div>
)}
</div>
) : null}
</nav>

<div className="mobile-sheet-foot">
Expand Down
26 changes: 13 additions & 13 deletions apps/web/app/components/SocialLoginButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
'use client';

/**
* SocialLoginButtons — Google, Facebook, Apple OAuth 로그인 버튼
* SocialLoginButtons — Google, Facebook, X(Twitter) OAuth 로그인 버튼
*
* 어떤 provider가 환경변수에 설정되어 있는지 서버에서 prop으로 받아
* 활성화된 버튼만 렌더링합니다.
*
* 사용 예시:
* <SocialLoginButtons
* callbackUrl="/mypage"
* enabledProviders={['google', 'facebook', 'apple']}
* enabledProviders={['google', 'facebook', 'twitter']}
* />
*/

import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { toast } from 'sonner';

type Provider = 'google' | 'facebook' | 'apple';
type Provider = 'google' | 'facebook' | 'twitter';

const PROVIDER_CONFIG: Record<Provider, { label: string; icon: React.ReactNode; style: React.CSSProperties }> = {
google: {
Expand All @@ -44,22 +44,22 @@ const PROVIDER_CONFIG: Record<Provider, { label: string; icon: React.ReactNode;
</svg>
),
style: {
background: '#1877F2',
color: '#ffffff',
border: '1px solid #1877F2',
background: '#ffffff',
color: '#3c4043',
border: '1px solid #dadce0',
},
},
apple: {
label: 'Continue with Apple',
twitter: {
label: 'Continue with X',
icon: (
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0f1419" aria-hidden="true">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.74l7.73-8.835L1.254 2.25H8.08l4.261 5.635zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
),
style: {
background: '#000000',
color: '#ffffff',
border: '1px solid #000000',
background: '#ffffff',
color: '#3c4043',
border: '1px solid #dadce0',
},
},
};
Expand Down
63 changes: 56 additions & 7 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2902,18 +2902,67 @@ p {

/* Responsive portal */
@media (max-width: 900px) {
.portal-sidebar { display: none; }
.portal-layout { flex-direction: column; }
.portal-sidebar { width: 100%; min-height: auto; position: static; flex-direction: row; flex-wrap: wrap; }
.portal-main { width: 100%; }
.portal-nav { flex-direction: row; flex-wrap: wrap; padding: 0.5rem; }
.portal-nav-item { padding: 0.5rem 0.75rem; font-size: 0.82rem; border-left: none; border-bottom: 3px solid transparent; }
.portal-nav-item:hover { border-left-color: transparent; border-bottom-color: var(--copper); }
.portal-sidebar-brand,
.portal-sidebar-footer { padding: 0.75rem 1rem; }
.portal-sidebar-footer { border-top: none; border-left: 1px solid rgba(255,255,255,0.08); }
.portal-stats { grid-template-columns: repeat(2, 1fr); }
}

/* Mobile portal sub-menu in hamburger sheet */
.mobile-portal-section {
border-bottom: 1px solid var(--line);
}
.mobile-portal-trigger {
font-family: var(--font-display);
font-size: 1.85rem;
font-weight: 400;
letter-spacing: -0.01em;
color: var(--ink);
padding: 1.1rem 0;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.mobile-portal-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.mobile-portal-arrow {
display: flex;
align-items: center;
color: var(--muted-soft);
transition: transform 0.2s ease;
}
.mobile-portal-submenu {
display: flex;
flex-direction: column;
padding-bottom: 0.5rem;
}
.mobile-portal-submenu a {
font-family: var(--font-body);
font-size: 1.05rem;
font-weight: 500;
color: var(--ink);
padding: 0.65rem 0 0.65rem 1.25rem;
border-bottom: 1px solid var(--line);
text-decoration: none;
display: flex;
align-items: center;
transition: color 0.15s ease;
}
.mobile-portal-submenu a:last-child {
border-bottom: none;
}
.mobile-portal-submenu a:hover {
color: var(--copper-deep);
}

@media (max-width: 600px) {
.portal-stats { grid-template-columns: 1fr 1fr; }
.portal-invoice-grid { grid-template-columns: 1fr; }
Expand Down
4 changes: 2 additions & 2 deletions apps/web/lib/auth-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
* social login buttons to render.
*/

export type OAuthProvider = 'google' | 'facebook' | 'apple';
export type OAuthProvider = 'google' | 'facebook' | 'twitter';

export function getEnabledProviders(): OAuthProvider[] {
const providers: OAuthProvider[] = [];
if (process.env.AUTH_GOOGLE_ID) providers.push('google');
if (process.env.AUTH_FACEBOOK_ID) providers.push('facebook');
if (process.env.AUTH_APPLE_ID) providers.push('apple');
if (process.env.AUTH_TWITTER_ID) providers.push('twitter');
return providers;
}
Loading
Loading