feat: add center toast notifications, compact reCAPTCHA badge, CG logo scroll-to-top

This commit is contained in:
ssbg04 committed 2026-06-06 21:48:06 +08:00
1 parent 8b485b5103
commit 3f6c67b975
4 files changed
+313 -26

No files matched your search

+98 -25
View File
@@ -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">&times;</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>
+200
View File
@@ -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;
}
+14
View File
@@ -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;
}
+1 -1
View File
@@ -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>