feat: add center toast notifications, compact reCAPTCHA badge, CG logo scroll-to-top
This commit is contained in:
1 parent
8b485b5103
commit
3f6c67b975
4 files changed
+313
-26
No files matched your search
+98
-25
@@ -223,6 +223,19 @@ export default async function Home() {
|
||||
|
||||
<div className="section-divider"></div>
|
||||
|
||||
{/* ===== TOAST NOTIFICATION ===== */}
|
||||
<div className="toast-backdrop" id="toast-backdrop" onClick={() => {}} />
|
||||
<div className="toast-container" id="toast-container" role="alert" aria-live="polite">
|
||||
<div className="toast" id="toast-box">
|
||||
<div className="toast-icon" id="toast-icon"></div>
|
||||
<div className="toast-body">
|
||||
<div className="toast-title" id="toast-title"></div>
|
||||
<div className="toast-msg" id="toast-msg"></div>
|
||||
</div>
|
||||
<button className="toast-close" id="toast-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== CONTACT ===== */}
|
||||
<ScrollReveal>
|
||||
<section id="contact" className="section-pad">
|
||||
@@ -261,73 +274,133 @@ export default async function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form action="/api/contact" method="POST" className="email-form" id="contact-form">
|
||||
<form className="email-form" id="contact-form">
|
||||
<input type="text" name="name" id="form-name" placeholder="Name" required />
|
||||
<input type="email" name="email" id="form-email" placeholder="Your Email" required />
|
||||
<input type="text" name="subject" id="form-subject" placeholder="Subject" required />
|
||||
<textarea name="message" id="form-message" rows={5} placeholder="Message" required></textarea>
|
||||
|
||||
{/* reCAPTCHA v3 / Enterprise implementation */}
|
||||
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" />
|
||||
|
||||
{/* Compact reCAPTCHA badge */}
|
||||
<div className="recaptcha-compact" title="Protected by reCAPTCHA Enterprise">
|
||||
<svg className="recaptcha-shield" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32 4L8 14v18c0 14 10.7 26.4 24 29 13.3-2.6 24-15 24-29V14L32 4z" fill="#4285F4" opacity="0.8"/>
|
||||
<path d="M28 38l-8-8 2.8-2.8L28 32.4l13.2-13.2L44 22 28 38z" fill="white"/>
|
||||
</svg>
|
||||
<div className="recaptcha-text">
|
||||
<span className="recaptcha-protected">Protected by reCAPTCHA</span>
|
||||
<span className="recaptcha-links">
|
||||
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener">Privacy</a>
|
||||
{" · "}
|
||||
<a href="https://policies.google.com/terms" target="_blank" rel="noopener">Terms</a>
|
||||
</span>
|
||||
</div>
|
||||
<div className="recaptcha-logo">re<br/>CAPTCHA</div>
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA v3 Enterprise — invisible */}
|
||||
<script src={`https://www.google.com/recaptcha/enterprise.js?render=${recaptchaSiteKey}`} async defer></script>
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var form = document.getElementById('contact-form');
|
||||
if (form) {
|
||||
(function() {
|
||||
function showToast(type, title, msg) {
|
||||
var container = document.getElementById('toast-container');
|
||||
var backdrop = document.getElementById('toast-backdrop');
|
||||
var box = document.getElementById('toast-box');
|
||||
var icon = document.getElementById('toast-icon');
|
||||
var titleEl = document.getElementById('toast-title');
|
||||
var msgEl = document.getElementById('toast-msg');
|
||||
var closeBtn = document.getElementById('toast-close');
|
||||
|
||||
box.className = 'toast toast-' + type;
|
||||
icon.textContent = type === 'success' ? '✓' : '✕';
|
||||
titleEl.textContent = title;
|
||||
msgEl.textContent = msg || '';
|
||||
|
||||
container.classList.add('toast-visible');
|
||||
backdrop.classList.add('toast-visible');
|
||||
|
||||
var autoClose;
|
||||
function dismiss() {
|
||||
container.classList.remove('toast-visible');
|
||||
backdrop.classList.remove('toast-visible');
|
||||
clearTimeout(autoClose);
|
||||
}
|
||||
|
||||
closeBtn.onclick = dismiss;
|
||||
backdrop.onclick = dismiss;
|
||||
autoClose = setTimeout(dismiss, type === 'success' ? 4000 : 6000);
|
||||
}
|
||||
|
||||
// Check URL params for status on load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var status = params.get('status');
|
||||
var msg = params.get('msg');
|
||||
if (status === 'success') {
|
||||
showToast('success', 'Message Sent!', 'Thank you for reaching out. I will get back to you soon.');
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash);
|
||||
} else if (status === 'error') {
|
||||
showToast('error', 'Something went wrong', msg || 'Please try again later.');
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash);
|
||||
}
|
||||
|
||||
var form = document.getElementById('contact-form');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function(event) {
|
||||
event.preventDefault(); // Stop normal submission
|
||||
event.preventDefault();
|
||||
var btn = document.getElementById('btn-send-message');
|
||||
var originalText = btn.innerText;
|
||||
btn.innerText = 'Verifying...';
|
||||
btn.disabled = true;
|
||||
|
||||
|
||||
grecaptcha.enterprise.ready(async function() {
|
||||
try {
|
||||
const token = await grecaptcha.enterprise.execute('${recaptchaSiteKey}', {action: 'submit'});
|
||||
|
||||
// 1. Verify reCAPTCHA token securely via our backend
|
||||
|
||||
const verifyRes = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: token })
|
||||
});
|
||||
|
||||
|
||||
const verifyData = await verifyRes.json();
|
||||
if (!verifyData.success) {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
window.location.href = '/?status=error&msg=' + encodeURIComponent(verifyData.msg) + '#contact';
|
||||
showToast('error', 'Verification Failed', verifyData.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Token is verified! Send email directly via Web3Forms to bypass Vercel block
|
||||
btn.innerText = 'Sending...';
|
||||
const formData = new FormData(form);
|
||||
formData.append("access_key", "${process.env.WEB3FORMS_ACCESS_KEY}");
|
||||
formData.append("from_name", "Portfolio Contact Form");
|
||||
|
||||
formData.append('access_key', '${process.env.WEB3FORMS_ACCESS_KEY}');
|
||||
formData.append('from_name', 'Portfolio Contact Form');
|
||||
|
||||
const web3Res = await fetch('https://api.web3forms.com/submit', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
|
||||
const web3Data = await web3Res.json();
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
|
||||
if (web3Data.success) {
|
||||
window.location.href = '/?status=success#contact';
|
||||
form.reset();
|
||||
showToast('success', 'Message Sent!', 'Thank you for reaching out. I will get back to you soon.');
|
||||
} else {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
window.location.href = '/?status=error&msg=Email service rejected the request.#contact';
|
||||
showToast('error', 'Something went wrong', 'The email service rejected the request. Please try again.');
|
||||
}
|
||||
|
||||
} catch(err) {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
alert('reCAPTCHA failed to load or an unexpected error occurred. Please try again.');
|
||||
showToast('error', 'Unexpected Error', 'reCAPTCHA failed to load. Please refresh and try again.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
`}} />
|
||||
|
||||
<button type="submit" className="btn primary full-width" id="btn-send-message">Send Message</button>
|
||||
|
||||
@@ -87,3 +87,203 @@ textarea:focus {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
TOAST NOTIFICATIONS
|
||||
===================================================== */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.85);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.toast-container.toast-visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px 28px;
|
||||
border-radius: 18px;
|
||||
min-width: 300px;
|
||||
max-width: 480px;
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255,255,255,0.05);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.toast-msg {
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.85;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: currentColor;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0 0 8px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 9998;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-backdrop.toast-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
RECAPTCHA RESPONSIVE / COMPACT
|
||||
===================================================== */
|
||||
.recaptcha-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.recaptcha-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
cursor: default;
|
||||
position: relative;
|
||||
transition: border-color 0.25s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recaptcha-compact:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.recaptcha-shield {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recaptcha-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recaptcha-protected {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-subtle);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.recaptcha-compact:hover .recaptcha-protected {
|
||||
max-height: 20px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recaptcha-links {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recaptcha-links a {
|
||||
color: var(--text-subtle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.recaptcha-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.recaptcha-logo {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-subtle);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -239,4 +239,18 @@
|
||||
bottom: 80px;
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
/* Toast responsive */
|
||||
.toast {
|
||||
min-width: 260px;
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide Google's floating reCAPTCHA badge — we have our own compact badge */
|
||||
.grecaptcha-badge {
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export default function Navbar() {
|
||||
|
||||
<nav id="main-nav">
|
||||
<div className="logo">
|
||||
<Link href="/" className="logo-link">CG</Link>
|
||||
<a href="/#" className="logo-link" onClick={(e) => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }}>CG</a>
|
||||
<Link href="/admin/login" className="logo-dot-link">.</Link>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user