first commit

This commit is contained in:
sandhiya-hepl
2026-07-14 15:20:26 +05:30
commit 4b8af23f5f
125 changed files with 15124 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+2852
View File
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
import { useEffect, useRef, useState, Component } from 'react';
import {
LayoutDashboard,
Sparkles,
Building2,
Award,
Layers,
Target,
Images,
Package,
FileText,
Link2,
Settings,
LogOut,
ExternalLink,
Save,
Menu,
X,
CheckCircle2,
AlertCircle,
Clock,
} from 'lucide-react';
import { adminApi, getToken, setToken, clearToken } from './api';
import { Field, ImageUpload, ArrayEditor } from './components/FormFields';
import { SectionPreview } from './components/SectionPreview';
import { pickSectionData } from './contentHelpers';
import defaultContent from '../../server/seed/default-content.json';
const NAV_GROUPS = [
{
label: 'Overview',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, desc: 'Overview and quick access to all sections' },
],
},
{
label: 'Page Content',
items: [
{ id: 'hero', label: 'Hero & Metrics', icon: Sparkles, desc: 'Homepage headline, video, and statistics' },
{ id: 'about', label: 'About & Partners', icon: Building2, desc: 'About section and partner logos' },
{ id: 'certifications', label: 'Certifications', icon: Award, desc: 'Certification badges and headings' },
{ id: 'services', label: 'Services', icon: Layers, desc: 'Service cards and section copy' },
{ id: 'whyUs', label: 'Why Us', icon: Target, desc: 'Value propositions and images' },
{ id: 'gallery', label: 'Gallery', icon: Images, desc: 'Featured case studies and gallery items' },
{ id: 'products', label: 'Products', icon: Package, desc: 'Product suite cards and stats' },
{ id: 'insights', label: 'Insights', icon: FileText, desc: 'Blog and insight carousel items' },
{ id: 'footer', label: 'Footer & Links', icon: Link2, desc: 'CTA, social links, and footer navigation' },
],
},
{
label: 'Account',
items: [
{ id: 'settings', label: 'Settings', icon: Settings, desc: 'Password and account preferences' },
],
},
];
const ALL_SECTIONS = NAV_GROUPS.flatMap((g) => g.items);
const SECTION_DESCRIPTIONS = Object.fromEntries(ALL_SECTIONS.map((s) => [s.id, s.desc]));
function Toast({ message, type }) {
if (!message) return null;
return (
<div className={`toast ${type}`}>
{type === 'success' ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
{message}
</div>
);
}
function LoginPreview() {
const viewportRef = useRef(null);
const [scale, setScale] = useState(0.5);
const previewWidth = 1280;
const previewHeight = 800;
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const updateScale = () => {
const nextScale = el.clientWidth / previewWidth;
setScale(nextScale);
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div className="login-preview">
<div className="login-preview-intro">
<img src="/assets/cavin_logo.svg" alt="Cavin Infotech" />
<div>
<h1>Content <span>Manager</span></h1>
<p>Preview the live website before you sign in. Edits publish instantly after login.</p>
</div>
<a href="/" target="_blank" rel="noopener noreferrer" className="login-preview-link">
<ExternalLink size={14} />
Open full site
</a>
</div>
<div className="login-preview-shell">
<div className="login-preview-chrome">
<span className="login-preview-dot" />
<span className="login-preview-dot" />
<span className="login-preview-dot" />
<span className="login-preview-url">cavininfotech.com</span>
</div>
<div
className="login-preview-viewport"
ref={viewportRef}
style={{ height: previewHeight * scale }}
>
<div
className="login-preview-stage"
style={{ width: previewWidth * scale, height: previewHeight * scale }}
>
<iframe
src="/"
title="Website preview"
className="login-preview-iframe"
style={{
width: previewWidth,
height: previewHeight,
transform: `scale(${scale})`,
}}
/>
</div>
</div>
</div>
</div>
);
}
function Login({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const { token } = await adminApi.login(username, password);
setToken(token);
onLogin();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<LoginPreview />
<div className="login-form-side">
<form className="login-card" onSubmit={handleSubmit}>
<h2>Welcome back</h2>
<p className="login-subtitle">Sign in to your admin account</p>
{error && <div className="login-error"><AlertCircle size={16} />{error}</div>}
<Field label="Username" value={username} onChange={setUsername} />
<Field label="Password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
function EditorPlaceholder({ loading, error }) {
return (
<div className="admin-editor-placeholder">
{loading ? (
<>
<div className="admin-spinner" />
<p>Loading section content...</p>
</>
) : (
<>
<AlertCircle size={22} />
<p>{error || 'Unable to load this section.'}</p>
</>
)}
</div>
);
}
class AdminErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div className="admin-card admin-editor-placeholder">
<AlertCircle size={22} />
<p>Something went wrong loading this section.</p>
<p style={{ fontSize: '0.8rem', color: '#94a3b8' }}>{this.state.error.message}</p>
<button type="button" className="btn-secondary" onClick={() => this.setState({ error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
function SectionEditor({ section, data, onChange }) {
if (!data) return <EditorPlaceholder loading={false} error="No content found for this section." />;
switch (section) {
case 'hero':
return (
<>
<ImageUpload label="Hero Video" value={data.hero?.video} onChange={(v) => onChange({ ...data, hero: { ...data.hero, video: v } })} />
<Field label="Badge Text" value={data.hero?.badge} onChange={(v) => onChange({ ...data, hero: { ...data.hero, badge: v } })} />
<Field label="Headline" value={data.hero?.headline} onChange={(v) => onChange({ ...data, hero: { ...data.hero, headline: v } })} />
<Field label="Subheadline" value={data.hero?.subheadline} onChange={(v) => onChange({ ...data, hero: { ...data.hero, subheadline: v } })} />
<ArrayEditor label="Metrics" items={data.metrics || []} onChange={(metrics) => onChange({ ...data, metrics })} fields={[{ key: 'value', label: 'Value' }, { key: 'label', label: 'Label' }]} />
</>
);
case 'about':
return (
<>
<Field label="About Heading" value={data.about?.heading} onChange={(v) => onChange({ about: { ...data.about, heading: v }, partners: data.partners })} />
<ImageUpload label="Badge Image" value={data.about?.badgeImage} onChange={(v) => onChange({ about: { ...data.about, badgeImage: v }, partners: data.partners })} />
<Field label="Badge Alt Text" value={data.about?.badgeAlt} onChange={(v) => onChange({ about: { ...data.about, badgeAlt: v }, partners: data.partners })} />
<ImageUpload label="Background Image" value={data.about?.backgroundImage} onChange={(v) => onChange({ about: { ...data.about, backgroundImage: v }, partners: data.partners })} />
<Field label="Partners Heading" value={data.partners?.heading} onChange={(v) => onChange({ about: data.about, partners: { ...data.partners, heading: v } })} />
<Field label="Partners Subheading" value={data.partners?.subheading} onChange={(v) => onChange({ about: data.about, partners: { ...data.partners, subheading: v } })} />
<Field label="Partners Body" value={data.partners?.body} onChange={(v) => onChange({ about: data.about, partners: { ...data.partners, body: v } })} multiline />
<ArrayEditor label="Partner Logos" items={data.partners?.logos || []} onChange={(logos) => onChange({ about: data.about, partners: { ...data.partners, logos } })} fields={[{ key: 'name', label: 'Name' }, { key: 'image', label: 'Logo', type: 'image' }]} />
</>
);
case 'certifications':
return (
<>
<Field label="Section Heading" value={data.heading} onChange={(v) => onChange({ ...data, heading: v })} />
<ArrayEditor label="Certifications" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[{ key: 'image', label: 'Image', type: 'image' }, { key: 'alt', label: 'Alt Text' }]} />
</>
);
case 'services':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Intro" value={data.intro} onChange={(v) => onChange({ ...data, intro: v })} multiline />
<ArrayEditor label="Service Cards" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'number', label: 'Number' }, { key: 'title', label: 'Title' }, { key: 'boldStatement', label: 'Bold Statement' },
{ key: 'description', label: 'Description', multiline: true }, { key: 'cta', label: 'CTA Text' },
]} />
</>
);
case 'whyUs':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<ArrayEditor label="Value Cards" items={data.cards || []} onChange={(cards) => onChange({ ...data, cards })} fields={[{ key: 'title', label: 'Title' }, { key: 'description', label: 'Description', multiline: true }]} />
<ArrayEditor label="Images" items={data.images || []} onChange={(images) => onChange({ ...data, images })} fields={[{ key: 'src', label: 'Image', type: 'image' }, { key: 'alt', label: 'Alt Text' }]} />
</>
);
case 'gallery':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Subtitle" value={data.subtitle} onChange={(v) => onChange({ ...data, subtitle: v })} multiline />
<ArrayEditor label="Gallery Items" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'title', label: 'Title' }, { key: 'category', label: 'Category' },
{ key: 'excerpt', label: 'Excerpt', multiline: true }, { key: 'image', label: 'Image', type: 'image' }, { key: 'readTime', label: 'Read Time' },
]} />
</>
);
case 'products':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Subtitle" value={data.subtitle} onChange={(v) => onChange({ ...data, subtitle: v })} multiline />
<ArrayEditor label="Products" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'name', label: 'Name' }, { key: 'description', label: 'Description', multiline: true }, { key: 'image', label: 'Dashboard Image', type: 'image' },
]} />
</>
);
case 'insights':
return (
<>
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Subtitle" value={data.subtitle} onChange={(v) => onChange({ ...data, subtitle: v })} multiline />
<ArrayEditor label="Insight Cards" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'category', label: 'Category' }, { key: 'title', label: 'Title' }, { key: 'date', label: 'Date' },
{ key: 'excerpt', label: 'Excerpt', multiline: true }, { key: 'image', label: 'Image', type: 'image' },
]} />
</>
);
case 'footer':
return (
<>
<Field label="CTA Headline" value={data.cta?.headline} onChange={(v) => onChange({ ...data, cta: { ...data.cta, headline: v } })} />
<Field label="CTA Body" value={data.cta?.body} onChange={(v) => onChange({ ...data, cta: { ...data.cta, body: v } })} multiline />
<ImageUpload label="CTA Image" value={data.cta?.image} onChange={(v) => onChange({ ...data, cta: { ...data.cta, image: v } })} />
<Field label="Primary Button" value={data.cta?.primaryButton} onChange={(v) => onChange({ ...data, cta: { ...data.cta, primaryButton: v } })} />
<Field label="Secondary Button" value={data.cta?.secondaryButton} onChange={(v) => onChange({ ...data, cta: { ...data.cta, secondaryButton: v } })} />
<ArrayEditor label="Quick Links" items={data.quickLinks || []} onChange={(quickLinks) => onChange({ ...data, quickLinks })} fields={[{ key: 'label', label: 'Label' }, { key: 'href', label: 'Link' }]} />
<ArrayEditor label="Product Links" items={data.productLinks || []} onChange={(productLinks) => onChange({ ...data, productLinks })} fields={[{ key: 'label', label: 'Label' }, { key: 'href', label: 'Link' }]} />
<ArrayEditor label="Social Links" items={data.social || []} onChange={(social) => onChange({ ...data, social })} fields={[{ key: 'platform', label: 'Platform' }, { key: 'url', label: 'URL' }]} />
<ArrayEditor label="Legal Links" items={data.legal || []} onChange={(legal) => onChange({ ...data, legal })} fields={[{ key: 'label', label: 'Label' }, { key: 'href', label: 'Link' }]} />
</>
);
default:
return null;
}
}
function SettingsPage() {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [msg, setMsg] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await adminApi.changePassword(current, next);
setMsg('Password updated successfully');
setCurrent('');
setNext('');
} catch (err) {
setMsg(err.message);
}
};
return (
<div className="admin-card settings-card">
<h1>Settings</h1>
<p className="subtitle">Update your admin password. Use at least 6 characters.</p>
<form onSubmit={handleSubmit}>
<Field label="Current Password" value={current} onChange={setCurrent} type="password" />
<Field label="New Password" value={next} onChange={setNext} type="password" />
{msg && <p className={msg.includes('success') ? 'msg-success' : 'msg-error'}>{msg}</p>}
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>
Update Password
</button>
</form>
</div>
);
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-IN', {
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
export default function App() {
const [authed, setAuthed] = useState(false);
const [checking, setChecking] = useState(true);
const [activeSection, setActiveSection] = useState('dashboard');
const [content, setContent] = useState(null);
const [contentLoading, setContentLoading] = useState(false);
const [contentError, setContentError] = useState('');
const [sectionData, setSectionData] = useState(null);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState({ message: '', type: '' });
const [sidebarOpen, setSidebarOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState(null);
const showToast = (message, type) => {
setToast({ message, type });
setTimeout(() => setToast({ message: '', type: '' }), 3500);
};
const goToSection = (id) => {
setActiveSection(id);
setSidebarOpen(false);
if (id === 'dashboard' || id === 'settings') {
setSectionData(null);
return;
}
if (!content) return;
setSectionData(pickSectionData(content, id));
};
useEffect(() => {
const onResize = () => {
if (window.innerWidth > 900) setSidebarOpen(false);
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
const token = getToken();
if (!token) { setChecking(false); return; }
adminApi.me().then(() => { setAuthed(true); setChecking(false); }).catch(() => { clearToken(); setChecking(false); });
}, []);
useEffect(() => {
if (!authed) return;
const prefetch = document.createElement('link');
prefetch.rel = 'prefetch';
prefetch.href = '/preview.html';
document.head.appendChild(prefetch);
setContentLoading(true);
setContentError('');
adminApi.getContent()
.then((data) => {
const { _updatedAt, ...rest } = data;
setContent(rest);
setLastUpdated(_updatedAt);
})
.catch((err) => {
console.warn('Admin content API failed, using seed fallback:', err.message);
setContent(defaultContent);
setContentError('Could not reach the API. Showing default content — start the server with npm run dev.');
})
.finally(() => setContentLoading(false));
}, [authed]);
useEffect(() => {
if (!content || activeSection === 'dashboard' || activeSection === 'settings') return;
setSectionData(pickSectionData(content, activeSection));
}, [content, activeSection]);
const handleSave = async () => {
setSaving(true);
try {
if (activeSection === 'hero') {
await adminApi.updateSection('hero', sectionData.hero);
await adminApi.updateSection('metrics', sectionData.metrics);
} else if (activeSection === 'about') {
await adminApi.updateSection('about', sectionData.about);
await adminApi.updateSection('partners', sectionData.partners);
} else {
await adminApi.updateSection(activeSection, sectionData);
}
const updated = await adminApi.getContent();
const { _updatedAt, ...rest } = updated;
setContent(rest);
setLastUpdated(_updatedAt);
showToast('Changes saved successfully', 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
setSaving(false);
}
};
if (checking) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<span>Loading admin panel...</span>
</div>
);
}
if (!authed) return <Login onLogin={() => setAuthed(true)} />;
const sectionInfo = ALL_SECTIONS.find((s) => s.id === activeSection);
const isEditor = activeSection !== 'dashboard' && activeSection !== 'settings';
return (
<div className="admin-layout">
<button
type="button"
className="admin-mobile-toggle"
aria-label="Toggle menu"
onClick={() => setSidebarOpen((o) => !o)}
>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
<div
className={`admin-sidebar-overlay ${sidebarOpen ? 'visible' : ''}`}
onClick={() => setSidebarOpen(false)}
/>
<aside className={`admin-sidebar ${sidebarOpen ? 'open' : ''}`}>
<div className="admin-sidebar-brand">
<img src="/assets/cavin_logo.svg" alt="Cavin Infotech" />
<h2>Content Manager</h2>
<p>Cavin Infotech Website</p>
</div>
<nav className="admin-nav">
{NAV_GROUPS.map((group) => (
<div key={group.label} className="admin-nav-group">
<div className="admin-nav-group-label">{group.label}</div>
{group.items.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
className={activeSection === id ? 'active' : ''}
onClick={() => goToSection(id)}
>
<Icon size={17} />
{label}
</button>
))}
</div>
))}
</nav>
<div className="admin-sidebar-footer">
<button type="button" className="logout-btn" onClick={() => { clearToken(); setAuthed(false); }}>
<LogOut size={15} />
Sign out
</button>
</div>
</aside>
<div className="admin-main-wrap">
<header className="admin-topbar">
<div>
<h1 className="admin-topbar-title">{sectionInfo?.label || 'Admin'}</h1>
<p className="admin-topbar-sub">{SECTION_DESCRIPTIONS[activeSection]}</p>
</div>
<div className="admin-topbar-actions">
<a href="/" target="_blank" rel="noopener noreferrer" className="btn-ghost">
<ExternalLink size={14} />
View site
</a>
{isEditor && (
<button type="button" className="btn-primary" onClick={handleSave} disabled={saving}>
<Save size={15} />
{saving ? 'Saving...' : 'Save changes'}
</button>
)}
</div>
</header>
<main className="admin-main">
{activeSection === 'dashboard' ? (
<>
<div className="dashboard-welcome">
<h2>Welcome to your CMS</h2>
<p>Edit any section below. All changes are saved to the database and appear on the live website immediately.</p>
<div className="dashboard-meta">
<span className="dashboard-meta-item">
<Clock size={13} />
Last updated: {formatDate(lastUpdated)}
</span>
</div>
</div>
<div className="dashboard-grid">
{ALL_SECTIONS.filter((s) => s.id !== 'dashboard').map(({ id, label, icon: Icon, desc }) => (
<button key={id} type="button" className="dashboard-tile" onClick={() => goToSection(id)}>
<div className="dashboard-tile-icon">
<Icon size={20} />
</div>
<h3>{label}</h3>
<p>{desc}</p>
</button>
))}
</div>
</>
) : activeSection === 'settings' ? (
<SettingsPage />
) : (
<AdminErrorBoundary>
<div className="admin-editor-layout">
<div className="admin-editor-form admin-card">
{contentLoading || !sectionData ? (
<EditorPlaceholder loading={contentLoading} error={contentError} />
) : (
<SectionEditor
key={activeSection}
section={activeSection}
data={sectionData}
onChange={setSectionData}
/>
)}
<div className="save-bar">
<button type="button" className="btn-secondary" onClick={() => goToSection('dashboard')}>
Cancel
</button>
<button type="button" className="btn-primary" onClick={handleSave} disabled={saving || !sectionData}>
<Save size={15} />
{saving ? 'Saving...' : 'Save changes'}
</button>
</div>
</div>
{!contentLoading && content && sectionData && (
<SectionPreview
section={activeSection}
label={sectionInfo?.label || 'Section'}
content={content}
sectionData={sectionData}
/>
)}
</div>
</AdminErrorBoundary>
)}
</main>
</div>
<Toast {...toast} />
</div>
);
}
+1131
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
const API_BASE = import.meta.env.VITE_API_URL || '';
export function getToken() {
return localStorage.getItem('admin_token');
}
export function setToken(token) {
localStorage.setItem('admin_token', token);
}
export function clearToken() {
localStorage.removeItem('admin_token');
}
async function apiFetch(path, options = {}) {
const headers = { ...options.headers };
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
export const adminApi = {
login: (username, password) =>
apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => apiFetch('/api/auth/me'),
getContent: () => apiFetch('/api/content'),
updateSection: (section, data) =>
apiFetch(`/api/content/${section}`, { method: 'PUT', body: JSON.stringify(data) }),
changePassword: (currentPassword, newPassword) =>
apiFetch('/api/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }),
upload: async (file) => {
const form = new FormData();
form.append('file', file);
return apiFetch('/api/upload', { method: 'POST', body: form });
},
};
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { Upload, Plus, Trash2 } from 'lucide-react';
import { adminApi } from '../api';
export function ImageUpload({ value, onChange, label = 'Image' }) {
const [uploading, setUploading] = useState(false);
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { url } = await adminApi.upload(file);
onChange(url);
} catch (err) {
alert(err.message);
} finally {
setUploading(false);
}
};
return (
<div className="form-field">
<label className="form-label">{label}</label>
{value && (
<div className="form-preview">
{value.match(/\.(mp4|webm)$/i) ? (
<video src={value} controls />
) : (
<img src={value} alt="" />
)}
</div>
)}
<input
type="text"
className="form-input"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="Paste URL or upload a file below"
/>
<label className="form-upload-btn">
<Upload size={14} />
{uploading ? 'Uploading...' : 'Choose file'}
<input type="file" accept="image/*,video/mp4" onChange={handleUpload} hidden disabled={uploading} />
</label>
</div>
);
}
export function Field({ label, value, onChange, multiline = false, type = 'text' }) {
return (
<div className="form-field">
<label className="form-label">{label}</label>
{multiline ? (
<textarea
className="form-textarea"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
) : (
<input
type={type}
className="form-input"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
/>
)}
</div>
);
}
export function ArrayEditor({ label, items, onChange, fields }) {
const updateItem = (index, key, val) => {
const next = items.map((item, i) => (i === index ? { ...item, [key]: val } : item));
onChange(next);
};
const addItem = () => {
const blank = fields.reduce((acc, f) => ({ ...acc, [f.key]: f.default ?? '' }), { id: Date.now() });
onChange([...items, blank]);
};
const removeItem = (index) => onChange(items.filter((_, i) => i !== index));
return (
<div className="array-section">
<div className="array-header">
<h4 className="array-title">{label}</h4>
<button type="button" onClick={addItem} className="array-add-btn">
<Plus size={14} /> Add item
</button>
</div>
{items.length === 0 && (
<p style={{ color: '#94a3b8', fontSize: '0.85rem', margin: '0 0 1rem' }}>No items yet. Click &quot;Add item&quot; to create one.</p>
)}
{items.map((item, index) => (
<div key={item.id ?? index} className="array-item">
<div className="array-item-header">
<span className="array-item-badge">Item {index + 1}</span>
<button type="button" onClick={() => removeItem(index)} className="array-remove-btn">
<Trash2 size={12} /> Remove
</button>
</div>
{fields.map((f) =>
f.type === 'image' ? (
<ImageUpload key={f.key} label={f.label} value={item[f.key]} onChange={(v) => updateItem(index, f.key, v)} />
) : (
<Field
key={f.key}
label={f.label}
value={item[f.key]}
onChange={(v) => updateItem(index, f.key, v)}
multiline={f.multiline}
/>
)
)}
</div>
))}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ExternalLink, Eye } from 'lucide-react';
export const SECTION_PREVIEW_ANCHORS = {
hero: 'home',
about: 'aboutus',
certifications: 'certifications',
services: 'services',
whyUs: 'whyus',
gallery: 'featured-gallery',
products: 'products',
insights: 'insights',
footer: 'contactus',
};
export function buildPreviewContent(baseContent, section, sectionData) {
if (!baseContent || !sectionData) return baseContent;
const merged = { ...baseContent };
if (section === 'hero') {
merged.hero = sectionData.hero ?? merged.hero;
merged.metrics = sectionData.metrics ?? merged.metrics;
} else if (section === 'about') {
merged.about = sectionData.about ?? merged.about;
merged.partners = sectionData.partners ?? merged.partners;
} else {
merged[section] = sectionData;
}
return merged;
}
const PREVIEW_SRC = '/preview.html';
const PREVIEW_WIDTH = 1440;
const PREVIEW_HEIGHT = 900;
export function SectionPreview({ section, label, content, sectionData }) {
const viewportRef = useRef(null);
const iframeRef = useRef(null);
const payloadRef = useRef({});
const debounceRef = useRef(null);
const anchor = SECTION_PREVIEW_ANCHORS[section] || 'home';
const [scale, setScale] = useState(0.45);
const [iframeReady, setIframeReady] = useState(false);
const [previewReady, setPreviewReady] = useState(false);
const [showIframe, setShowIframe] = useState(false);
payloadRef.current = { content, sectionData, section, anchor };
// Defer iframe mount so the editor form paints first
useEffect(() => {
const timer = window.setTimeout(() => setShowIframe(true), 50);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'PREVIEW_READY') setPreviewReady(true);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const updateScale = () => {
const width = el.clientWidth;
setScale(width > 0 ? width / PREVIEW_WIDTH : 0.45);
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(el);
return () => observer.disconnect();
}, []);
const postPreview = useCallback((immediate = false) => {
const send = () => {
const iframe = iframeRef.current;
const { content: base, sectionData: data, section: sec, anchor: id } = payloadRef.current;
if (!iframe?.contentWindow || !base || !data) return;
iframe.contentWindow.postMessage(
{
type: 'ADMIN_PREVIEW',
section: sec,
anchor: id,
fullContent: buildPreviewContent(base, sec, data),
},
window.location.origin,
);
};
if (immediate) {
window.clearTimeout(debounceRef.current);
send();
return;
}
window.clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(send, 200);
}, []);
useEffect(() => {
if (!iframeReady) return;
postPreview(true);
}, [iframeReady, section, anchor, iframeReady, postPreview]);
useEffect(() => {
if (!iframeReady) return;
postPreview(false);
return () => window.clearTimeout(debounceRef.current);
}, [content, sectionData, iframeReady, postPreview]);
const handleIframeLoad = () => {
setIframeReady(true);
postPreview(true);
};
const isLoading = !showIframe || !iframeReady || !previewReady;
return (
<aside className="admin-preview-panel">
<div className="admin-preview-panel-header">
<div>
<span className="admin-preview-eyebrow">
<Eye size={14} />
Live preview
</span>
<h3>{label}</h3>
</div>
<a
href={`/#${anchor}`}
target="_blank"
rel="noopener noreferrer"
className="admin-preview-open"
>
<ExternalLink size={13} />
Open
</a>
</div>
<div className="admin-preview-shell">
<div className="admin-preview-chrome">
<span className="admin-preview-dot" />
<span className="admin-preview-dot" />
<span className="admin-preview-dot" />
<span className="admin-preview-url">/{anchor}</span>
</div>
<div
className="admin-preview-viewport"
ref={viewportRef}
style={{ height: PREVIEW_HEIGHT * scale }}
>
{isLoading && (
<div className="admin-preview-loading">
<div className="admin-spinner" />
<span>Loading preview</span>
</div>
)}
{showIframe && (
<div
className="admin-preview-stage"
style={{ width: PREVIEW_WIDTH * scale, height: PREVIEW_HEIGHT * scale, opacity: isLoading ? 0 : 1 }}
>
<iframe
ref={iframeRef}
src={PREVIEW_SRC}
title={`${label} preview`}
className="admin-preview-iframe"
onLoad={handleIframeLoad}
style={{
width: PREVIEW_WIDTH,
height: PREVIEW_HEIGHT,
transform: `scale(${scale})`,
}}
/>
</div>
)}
</div>
</div>
<p className="admin-preview-note">Updates as you edit. Save to publish to the live site.</p>
</aside>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function pickSectionData(content, section) {
if (!content || section === 'dashboard' || section === 'settings') return null;
if (section === 'hero') {
return {
hero: structuredClone(content.hero ?? {}),
metrics: structuredClone(content.metrics ?? []),
};
}
if (section === 'about') {
return {
about: structuredClone(content.about ?? {}),
partners: structuredClone(content.partners ?? { logos: [] }),
};
}
const data = content[section];
return data ? structuredClone(data) : null;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './admin.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+296
View File
@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import { ArrowRight, Clock } from 'lucide-react';
export default function FeaturedGallery({ gallery }) {
const galleryData = gallery?.items || [];
const header = gallery || { eyebrow: 'Featured Gallery', title: '', subtitle: '' };
const [activeGalleryIndex, setActiveGalleryIndex] = useState(0);
const [isMobile, setIsMobile] = useState(false);
React.useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 1024);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return (
<section id="featured-gallery" style={{ padding: isMobile ? '4rem 1.25rem' : '6rem 8%', background: '#020710', position: 'relative' }}>
<div style={{ marginBottom: '3.5rem', position: 'relative', zIndex: 10 }}>
<span style={{ color: '#ffaa00', fontSize: '0.9rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
{header.eyebrow}
</span>
<h2 style={{ fontSize: 'clamp(2rem, 3vw, 2.5rem)', fontWeight: 800, marginTop: '0.5rem', color: '#ffffff' }}>
{header.title}
</h2>
<p style={{ color: '#94a3b8', fontSize: '1rem', marginTop: '0.75rem', maxWidth: '600px', lineHeight: '1.5' }}>
{header.subtitle}
</p>
</div>
<ResponsiveGalleryWrapper
galleryData={galleryData}
activeIdx={activeGalleryIndex}
setActiveIdx={setActiveGalleryIndex}
/>
</section>
);
}
function ResponsiveGalleryWrapper({ galleryData, activeIdx, setActiveIdx }) {
const [isMobileSize, setIsMobileSize] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
React.useEffect(() => {
const handleResize = () => {
setIsMobileSize(window.innerWidth < 1024);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const getRevealProps = (index) => {
if (shouldReduceMotion) return {};
return {
initial: { opacity: 0, y: 40 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, amount: 0.15 }
};
};
if (isMobileSize) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{galleryData.map((item, index) => {
const isFirst = index === 0;
const revealProps = getRevealProps(index);
return (
<motion.div
key={item.id}
className="glass-card"
{...revealProps}
transition={{
duration: 0.8,
ease: [0.22, 1, 0.36, 1],
delay: index * 0.1
}}
style={{
borderRadius: '24px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.06)',
position: 'relative',
height: isFirst ? '320px' : '240px',
backgroundImage: `linear-gradient(to bottom, rgba(2, 7, 16, 0.3) 0%, rgba(2, 7, 16, 0.95) 100%), url(${item.image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '2rem 1.5rem',
boxShadow: isFirst ? '0 10px 30px -10px rgba(255, 170, 0, 0.15)' : 'none',
borderColor: isFirst ? 'rgba(255, 170, 0, 0.3)' : 'rgba(255, 255, 255, 0.06)'
}}
>
<span style={{
color: '#ffaa00',
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '0.25rem'
}}>
{item.category}
</span>
<h3 style={{ fontSize: '1.4rem', fontWeight: 700, color: '#ffffff', margin: '0 0 0.5rem 0', lineHeight: '1.3' }}>
{item.title}
</h3>
<p style={{ color: '#cbd5e1', fontSize: '0.88rem', margin: '0 0 1rem 0', lineHeight: '1.5', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{item.excerpt}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid rgba(255, 255, 255, 0.1)', paddingTop: '0.75rem' }}>
<span style={{ color: '#94a3b8', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Clock size={14} /> {item.readTime}
</span>
<span style={{ color: '#ffaa00', fontSize: '0.85rem', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
Read More <ArrowRight size={14} />
</span>
</div>
</motion.div>
);
})}
</div>
);
}
// Desktop horizontal layout
return (
<div style={{
display: 'flex',
gap: '1.25rem',
width: '100%',
height: '460px',
alignItems: 'stretch',
position: 'relative'
}}>
{galleryData.map((item, index) => {
const isActive = activeIdx === index;
const revealProps = getRevealProps(index);
return (
<motion.div
key={item.id}
layout
onMouseEnter={() => setActiveIdx(index)}
className="glass-card"
{...revealProps}
transition={shouldReduceMotion ? { type: 'spring', stiffness: 350, damping: 32 } : {
layout: { type: 'spring', stiffness: 350, damping: 32 },
y: { duration: 0.8, ease: [0.22, 1, 0.36, 1], delay: index * 0.1 },
opacity: { duration: 0.8, ease: 'easeOut', delay: index * 0.1 }
}}
style={{
flex: isActive ? 2.8 : 1,
borderRadius: '24px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.06)',
position: 'relative',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
boxShadow: isActive ? '0 15px 35px -12px rgba(255, 170, 0, 0.2)' : '0 4px 20px rgba(0, 0, 0, 0.3)',
borderColor: isActive ? 'rgba(255, 170, 0, 0.3)' : 'rgba(255, 255, 255, 0.06)',
}}
>
{/* Background image container for smooth scale transform */}
<motion.div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: `url(${item.image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
zIndex: 1
}}
animate={{ scale: isActive ? 1.06 : 1 }}
transition={{ duration: 0.6 }}
/>
{/* Dark Overlay */}
<motion.div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to bottom, rgba(2, 7, 16, 0.3) 0%, rgba(2, 7, 16, 0.95) 100%)',
zIndex: 2
}}
animate={{ opacity: isActive ? 0.95 : 0.85 }}
transition={{ duration: 0.4 }}
/>
{/* Top Tag */}
<div style={{ position: 'relative', zIndex: 3, padding: '2rem 1.8rem 0 1.8rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{
color: '#ffaa00',
fontSize: '0.75rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
background: 'rgba(255, 170, 0, 0.08)',
padding: '0.3rem 0.6rem',
borderRadius: '8px',
border: '1px solid rgba(255, 170, 0, 0.15)'
}}>
{item.category}
</span>
</div>
{/* Bottom Content Area */}
<div style={{ position: 'relative', zIndex: 3, padding: '0 1.8rem 2rem 1.8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<h3 style={{
fontSize: isActive ? '1.5rem' : '1.15rem',
fontWeight: 700,
color: '#ffffff',
margin: 0,
lineHeight: '1.3',
transition: 'font-size 0.3s ease',
whiteSpace: isActive ? 'normal' : 'nowrap',
overflow: isActive ? 'visible' : 'hidden',
textOverflow: isActive ? 'clip' : 'ellipsis',
maxWidth: '100%'
}}>
{item.title}
</h3>
{/* Excerpt - Animate visibility based on active state */}
<motion.div
initial={false}
animate={{
height: isActive ? 'auto' : 0,
opacity: isActive ? 1 : 0,
marginTop: isActive ? 6 : 0
}}
transition={{ duration: 0.4, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<p style={{ color: '#cbd5e1', fontSize: '0.88rem', margin: 0, lineHeight: '1.5', maxWidth: '90%' }}>
{item.excerpt}
</p>
</motion.div>
</div>
{/* Progress Line and Footer Area */}
<div style={{ marginTop: '1.5rem' }}>
<div style={{
width: '100%',
height: '2px',
backgroundColor: 'rgba(255, 255, 255, 0.12)',
borderRadius: '1px',
marginBottom: '1rem',
position: 'relative',
overflow: 'hidden'
}}>
<motion.div
style={{
height: '100%',
backgroundColor: '#ffaa00',
borderRadius: '1px'
}}
initial={{ width: 0 }}
animate={{ width: isActive ? '60%' : '15%' }}
transition={{ duration: 0.5 }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', minHeight: '24px' }}>
<span style={{ color: '#94a3b8', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Clock size={13} style={{ color: '#ffaa00' }} /> {item.readTime}
</span>
{isActive && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
style={{ color: '#ffaa00', fontSize: '0.85rem', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.25rem' }}
>
Read More <ArrowRight size={14} />
</motion.span>
)}
</div>
</div>
</div>
</motion.div>
);
})}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { motion, useReducedMotion } from 'framer-motion';
const ACCENT_GRADIENTS = [
'radial-gradient(circle at top right, rgba(139, 92, 246, 0.08) 0%, transparent 50%), linear-gradient(to bottom, #FAF9FA 0%, #F6F2F5 100%)',
'radial-gradient(circle at top right, rgba(16, 185, 129, 0.08) 0%, transparent 50%), linear-gradient(to bottom, #FAF9FA 0%, #F6F2F5 100%)',
'radial-gradient(circle at top right, rgba(245, 158, 11, 0.08) 0%, transparent 50%), linear-gradient(to bottom, #FAF9FA 0%, #F6F2F5 100%)',
];
export default function ProductCard({ product, isMobile, delay = 0, index = 0 }) {
if (!product) return null;
const shouldReduceMotion = useReducedMotion();
const dashWidth = isMobile ? '92%' : '520px';
const dashLeft = isMobile ? '4%' : '32%';
const dashHeight = isMobile ? '200px' : '320px';
const shadowHeight = isMobile ? '170px' : '280px';
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 40 }}
whileInView={shouldReduceMotion ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1], delay }}
style={{ height: '100%' }}
>
<div
className="product-card"
style={{
borderRadius: isMobile ? '20px' : '28px',
border: `${isMobile ? 4 : 6}px solid #EAE3E8`,
background: ACCENT_GRADIENTS[index % ACCENT_GRADIENTS.length],
padding: isMobile ? '1.75rem 1.25rem 0' : '3rem 2.5rem 0 2.5rem',
boxShadow: '0 4px 24px rgba(234, 227, 232, 0.25)',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: isMobile ? '380px' : '600px',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
onMouseEnter={(e) => {
if (isMobile) return;
e.currentTarget.style.transform = 'translateY(-6px)';
e.currentTarget.style.boxShadow = '0 10px 30px rgba(255, 170, 0, 0.2)';
e.currentTarget.style.borderColor = '#ffaa00';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 24px rgba(234, 227, 232, 0.25)';
e.currentTarget.style.borderColor = '#EAE3E8';
}}
>
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'radial-gradient(#E0D2DC 1.5px, transparent 1.5px)',
backgroundSize: '12px 12px',
WebkitMaskImage: 'linear-gradient(to top, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0) 65%)',
maskImage: 'linear-gradient(to top, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0) 65%)',
pointerEvents: 'none',
zIndex: 1,
}}
/>
<h3 style={{
fontSize: isMobile ? '1.5rem' : '2rem',
fontWeight: 800,
color: '#0f172a',
margin: '0 0 0.75rem 0',
letterSpacing: '-0.02em',
position: 'relative',
zIndex: 3,
}}>
{product.name}
</h3>
<p style={{
color: '#475569',
fontSize: isMobile ? '0.95rem' : '1.05rem',
lineHeight: '1.6',
margin: '0 0 1.5rem 0',
fontWeight: 400,
maxWidth: '100%',
position: 'relative',
zIndex: 3,
}}>
{product.description}
</p>
<div style={{
position: 'absolute',
bottom: 0,
left: dashLeft,
width: dashWidth,
height: shadowHeight,
borderRadius: '20px 20px 0 0',
boxShadow: '-20px -20px 45px rgba(0,0,0,0.12)',
zIndex: 2,
pointerEvents: 'none',
}} />
<div style={{
position: 'absolute',
bottom: isMobile ? '-16px' : '-40px',
left: dashLeft,
width: dashWidth,
height: dashHeight,
borderRadius: '16px',
border: '1.5px solid rgba(255, 255, 255, 0.4)',
overflow: 'hidden',
zIndex: 2,
background: 'transparent',
}}>
<img
src={product.image}
alt={`${product.name} dashboard`}
style={{
position: 'absolute',
top: isMobile ? '0' : '-8%',
left: isMobile ? '0' : '-40%',
width: isMobile ? '100%' : '160%',
height: isMobile ? '100%' : '145%',
objectFit: 'cover',
}}
/>
</div>
{product.stats?.[0] && (
<div style={{
position: 'absolute',
bottom: isMobile ? '110px' : '160px',
left: isMobile ? '6%' : '18%',
background: 'rgba(255, 255, 255, 0.25)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255, 255, 255, 0.3)',
padding: isMobile ? '0.5rem 0.75rem' : '0.8rem 1.2rem',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(31, 38, 135, 0.15)',
zIndex: 5,
}}>
<span style={{ fontWeight: 800, color: '#000000', fontSize: isMobile ? '1.1rem' : '1.4rem', display: 'block', lineHeight: 1.1 }}>
{product.stats[0].value}
</span>
<span style={{ fontSize: isMobile ? '0.65rem' : '0.75rem', color: '#000000', fontWeight: 600 }}>
{product.stats[0].label}
</span>
</div>
)}
{product.stats?.[1] && (
<div style={{
position: 'absolute',
bottom: isMobile ? '36px' : '50px',
right: isMobile ? '5%' : '8%',
background: 'rgba(20, 20, 25, 0.55)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255, 255, 255, 0.15)',
padding: isMobile ? '0.5rem 0.75rem' : '0.8rem 1.2rem',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.25)',
zIndex: 5,
}}>
<span style={{ fontWeight: 800, color: '#ffffff', fontSize: isMobile ? '1.1rem' : '1.4rem', display: 'block', lineHeight: 1.1 }}>
{product.stats[1].value}
</span>
<span style={{ fontSize: isMobile ? '0.65rem' : '0.75rem', color: '#ffffff', fontWeight: 500 }}>
{product.stats[1].label}
</span>
</div>
)}
</div>
</motion.div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import React from 'react';
import * as Icons from 'lucide-react';
const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
const describeArc = (x, y, radius, startAngle, endAngle) => {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
return [
'M', start.x, start.y,
'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y
].join(' ');
};
export default function ServiceDial({ activeIndex, services }) {
const center = 160;
const radius = 110;
const strokeWidth = 14;
const gap = 4; // degrees
const activeService = services[activeIndex] || services[0];
const IconComponent = Icons[activeService.icon] || Icons.Sparkles;
return (
<div style={{
position: 'relative',
width: '100%',
maxWidth: '380px',
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'radial-gradient(circle, rgba(255, 170, 0, 0.05) 0%, rgba(2, 7, 16, 0.4) 70%)',
borderRadius: '50%',
border: '1px solid rgba(255, 255, 255, 0.03)',
boxShadow: 'inset 0 0 40px rgba(0, 240, 255, 0.03), 0 20px 50px rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(10px)',
padding: '20px'
}}>
{/* Outer Rotating Ring */}
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
animation: 'spin-slow 25s linear infinite',
transformOrigin: 'center'
}}
viewBox="0 0 320 320"
>
<circle
cx={center}
cy={center}
r={radius + 24}
fill="none"
stroke="rgba(0, 240, 255, 0.15)"
strokeWidth="1.5"
strokeDasharray="4 8"
/>
<circle
cx={center}
cy={center}
r={radius + 30}
fill="none"
stroke="rgba(255, 170, 0, 0.08)"
strokeWidth="1"
strokeDasharray="40 16"
/>
</svg>
{/* Main Dial Canvas */}
<svg
viewBox="0 0 320 320"
style={{
width: '100%',
height: '100%',
transform: 'rotate(0deg)'
}}
>
<defs>
{/* Active Segment Glow Filter */}
<filter id="glow-gold" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Core Gold Radial Gradient */}
<radialGradient id="radial-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="rgba(255, 170, 0, 0.2)" />
<stop offset="100%" stopColor="rgba(2, 7, 16, 0)" />
</radialGradient>
</defs>
{/* Inner Radial Glow */}
<circle cx={center} cy={center} r={radius - 10} fill="url(#radial-glow)" />
{/* 8 Segments */}
{services.map((service, idx) => {
const startAngle = idx * 45 + gap;
const endAngle = (idx + 1) * 45 - gap;
const pathD = describeArc(center, center, radius, startAngle, endAngle);
const isActive = idx === activeIndex;
const isCompleted = idx < activeIndex;
let strokeColor = 'rgba(255, 255, 255, 0.05)';
let filterVal = 'none';
let opacityVal = 0.4;
if (isActive) {
strokeColor = '#ffaa00'; // Vibrant Gold
filterVal = 'url(#glow-gold)';
opacityVal = 1;
} else if (isCompleted) {
strokeColor = 'rgba(255, 170, 0, 0.45)'; // Subtly Filled Gold/Amber
opacityVal = 0.8;
} else {
strokeColor = 'rgba(0, 240, 255, 0.08)'; // Inactive dark cyan/blue segment
opacityVal = 0.35;
}
return (
<path
key={service.id}
d={pathD}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
filter={filterVal}
style={{
opacity: opacityVal,
transition: 'stroke 0.6s ease, filter 0.6s ease, opacity 0.6s ease',
}}
/>
);
})}
{/* Inner Ring Border */}
<circle
cx={center}
cy={center}
r={radius - 15}
fill="none"
stroke="rgba(255, 255, 255, 0.04)"
strokeWidth="1"
/>
</svg>
{/* Center content overlay */}
<div style={{
position: 'absolute',
width: `${(radius - 20) * 2}px`,
height: `${(radius - 20) * 2}px`,
borderRadius: '50%',
background: 'radial-gradient(circle, #081125 0%, #030712 100%)',
border: '1px solid rgba(255, 170, 0, 0.15)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '20px',
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.8)',
zIndex: 5
}}>
{/* Glow indicator at the center top */}
<div style={{
position: 'absolute',
top: '-1px',
width: '40px',
height: '2px',
background: '#ffaa00',
boxShadow: '0 0 10px #ffaa00'
}} />
<div style={{
width: '46px',
height: '46px',
borderRadius: '12px',
background: 'linear-gradient(135deg, rgba(255, 170, 0, 0.1) 0%, rgba(255, 170, 0, 0.02) 100%)',
border: '1px solid rgba(255, 170, 0, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '10px',
color: '#ffaa00',
boxShadow: '0 4px 15px rgba(255, 170, 0, 0.1)'
}}>
<IconComponent size={22} strokeWidth={1.8} />
</div>
<span style={{
fontSize: '0.65rem',
fontWeight: 700,
color: '#00f0ff',
textTransform: 'uppercase',
letterSpacing: '0.15em',
marginBottom: '4px'
}}>
Service {activeService.number}
</span>
<h4 style={{
fontSize: '0.85rem',
fontWeight: 800,
color: '#ffffff',
margin: 0,
lineHeight: '1.3',
maxWidth: '120px',
letterSpacing: '-0.01em'
}}>
{activeService.title}
</h4>
</div>
{/* Styled animation for keyframes */}
<style>{`
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState } from 'react';
import defaultContent from '../../server/seed/default-content.json';
const ContentContext = createContext(null);
const API_BASE = import.meta.env.VITE_API_URL || '';
export function ContentProvider({ children, previewMode = false }) {
const [content, setContent] = useState(defaultContent);
const [loading, setLoading] = useState(!previewMode);
const [error, setError] = useState(null);
const [updatedAt, setUpdatedAt] = useState(null);
useEffect(() => {
if (previewMode) return;
fetch(`${API_BASE}/api/content`)
.then((res) => {
if (!res.ok) throw new Error('Failed to load content');
return res.json();
})
.then((data) => {
const { _updatedAt, ...rest } = data;
setContent(rest);
setUpdatedAt(_updatedAt);
setError(null);
})
.catch((err) => {
console.warn('Using fallback content:', err.message);
setError(err.message);
})
.finally(() => setLoading(false));
}, [previewMode]);
useEffect(() => {
if (!previewMode) return;
setLoading(false);
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'ADMIN_PREVIEW' || !event.data.fullContent) return;
setContent(event.data.fullContent);
setLoading(false);
const anchor = event.data.anchor;
if (!anchor) return;
window.setTimeout(() => {
document.getElementById(anchor)?.scrollIntoView({ behavior: 'auto', block: 'start' });
}, 180);
};
window.addEventListener('message', onMessage);
window.parent?.postMessage({ type: 'PREVIEW_READY' }, window.location.origin);
return () => window.removeEventListener('message', onMessage);
}, [previewMode]);
return (
<ContentContext.Provider value={{ content, loading, error, updatedAt, previewMode }}>
{children}
</ContentContext.Provider>
);
}
export function useContent() {
const ctx = useContext(ContentContext);
if (!ctx) throw new Error('useContent must be used within ContentProvider');
return ctx;
}
+7
View File
@@ -0,0 +1,7 @@
import { createContext, useContext } from 'react';
export const PreviewContext = createContext(false);
export function usePreviewMode() {
return useContext(PreviewContext);
}
+98
View File
@@ -0,0 +1,98 @@
export const services = [
{
id: "ai-data-services",
number: "01",
title: "AI & Data Services",
boldStatement: "Your Data, Unlocked.",
description: "Harness Generative AI, Agentic AI, and predictive analytics to automate complex workflows, predict market shifts, and unlock hidden revenue — powered by modern AI architectures.",
capabilities: [
"Generative AI & LLM Solutions",
"Agentic AI & Autonomous Systems",
"Predictive Analytics & Forecasting",
"Conversational AI & Enterprise Assistants"
],
cta: "Explore AI Solutions",
glowColor: "radial-gradient(circle at bottom right, rgba(255, 120, 0, 0.15) 0%, rgba(2, 7, 16, 0) 70%)",
borderColor: "rgba(255, 120, 0, 0.12)"
},
{
id: "sap-managed-services",
number: "02",
title: "SAP Managed Services",
boldStatement: "SAP That Works. So You Can Focus.",
description: "Certified SAP consultants delivering functional expertise, ABAP development, security, upgrades, and license optimization — keeping your enterprise running smooth and audit-ready.",
capabilities: [
"SAP Functional Consulting",
"SAP ABAP Development",
"SAP Security & Compliance",
"SAP Upgrades & Optimization"
],
cta: "Explore SAP Solutions",
glowColor: "radial-gradient(circle at bottom right, rgba(0, 240, 255, 0.15) 0%, rgba(2, 7, 16, 0) 70%)",
borderColor: "rgba(0, 240, 255, 0.12)"
},
{
id: "enterprise-development",
number: "03",
title: "Enterprise Development",
boldStatement: "Built to Scale With You.",
description: "Custom enterprise applications that modernize legacy workflows, connect business systems, and deliver reliable digital operations as your organization evolves.",
capabilities: [
"Custom Enterprise Applications",
"Mobile & Web Platforms",
"Modernization of Legacy Systems",
"Workflow Automation"
],
cta: "Explore Enterprise Solutions",
glowColor: "radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.12) 0%, rgba(255, 120, 0, 0.08) 50%, rgba(2, 7, 16, 0) 100%)",
borderColor: "rgba(59, 130, 246, 0.12)"
},
{
id: "architecture-as-a-service",
number: "04",
title: "Architecture as a Service",
boldStatement: "Design the Foundation Before You Build.",
description: "We help businesses define scalable technology architecture, integration models, cloud strategy, and modernization roadmaps before execution begins.",
capabilities: [
"Enterprise Architecture",
"Solution Architecture",
"Cloud Architecture",
"Integration Architecture"
],
cta: "Explore Architecture Solutions",
glowColor: "radial-gradient(circle at bottom right, rgba(29, 78, 216, 0.15) 0%, rgba(2, 7, 16, 0) 70%)",
borderColor: "rgba(29, 78, 216, 0.12)"
},
{
id: "smart-factory-iot",
number: "05",
title: "Smart Factory / Industrial IoT",
boldStatement: "Make Operations Visible, Connected, and Intelligent.",
description: "Connect machines, sensors, production systems, and dashboards to enable real-time monitoring, predictive maintenance, and smarter factory decisions.",
capabilities: [
"Industrial IoT Integration",
"Real-Time Machine Monitoring",
"Predictive Maintenance",
"Factory Dashboards"
],
cta: "Explore Smart Factory Solutions",
glowColor: "radial-gradient(circle at bottom right, rgba(255, 120, 0, 0.08) 0%, rgba(59, 130, 246, 0.08) 50%, rgba(2, 7, 16, 0) 100%)",
borderColor: "rgba(255, 120, 0, 0.1)"
},
{
id: "ar-vr-solutions-digifox",
number: "06",
title: "AR/VR Solutions — DigiFox",
boldStatement: "Immersive Experiences for Training, Learning, and Visualization.",
description: "Create virtual environments, simulations, AR product experiences, and immersive learning platforms that improve engagement, understanding, and retention.",
capabilities: [
"VR Training Simulations",
"AR Product Visualization",
"Immersive Learning Experiences",
"3D Interaction Design"
],
cta: "Explore DigiFox Solutions",
glowColor: "radial-gradient(circle at bottom right, rgba(139, 92, 246, 0.15) 0%, rgba(2, 7, 16, 0) 70%)",
borderColor: "rgba(139, 92, 246, 0.12)"
}
];
+246
View File
@@ -0,0 +1,246 @@
@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:ital,wght@0,400..900;1,400..900&display=swap');
:root {
font-family: 'Schibsted Grotesk', sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #020710;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Custom Color Tokens */
--bg-primary: #020710;
--bg-secondary: #051022;
--bg-tertiary: #08162d;
--bg-card: rgba(10, 25, 47, 0.7);
--accent-gold: #ffaa00;
--accent-orange: #ff5500;
--accent-cyan: #00f0ff;
--accent-blue: #0077ff;
--text-primary: #ffffff;
--text-secondary: #a0aec0;
--text-muted: #64748b;
--border-glow: rgba(0, 240, 255, 0.15);
--border-gold: rgba(255, 170, 0, 0.2);
--border-glass: rgba(255, 255, 255, 0.08);
--gradient-gold: linear-gradient(135deg, #ffca28 0%, #ff8f00 50%, #ff6f00 100%);
--gradient-neon: linear-gradient(135deg, #00f0ff 0%, #0072ff 100%);
--gradient-dark: linear-gradient(180deg, #020710 0%, #051022 100%);
--gradient-glow-orange: radial-gradient(circle at 50% 50%, rgba(255, 85, 0, 0.15) 0%, transparent 60%);
--gradient-glow-blue: radial-gradient(circle at 50% 50%, rgba(0, 240, 255, 0.1) 0%, transparent 60%);
}
html {
scroll-behavior: smooth;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
overflow-x: hidden;
}
/* Animations */
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0px); }
}
@keyframes spin-slow {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse-slow {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.9; }
}
@keyframes marquee {
0% { transform: translateX(0%); }
100% { transform: translateX(-50%); }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-spin-slow {
animation: spin-slow 20s linear infinite;
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
/* Utilities */
.glass {
background: rgba(5, 16, 34, 0.4);
backdrop-filter: blur(16px) saturate(120%);
-webkit-backdrop-filter: blur(16px) saturate(120%);
border: 1px solid var(--border-glass);
}
.glass-card {
background: rgba(10, 25, 47, 0.35);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card:hover {
border-color: rgba(0, 240, 255, 0.3);
box-shadow: 0 10px 30px -10px rgba(0, 240, 255, 0.15);
transform: translateY(-4px);
}
.gradient-text-gold {
background: var(--gradient-gold);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.gradient-text-cyan {
background: var(--gradient-neon);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.btn-sparkle-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.btn-sparkles {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 430px;
height: 96px;
pointer-events: none;
z-index: 1;
mix-blend-mode: screen;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
filter: invert(1) brightness(2);
}
.btn-sparkle-wrapper:hover .btn-sparkles {
opacity: 0.2;
transform: translate(-50%, -50%) scale(1.05);
}
.btn-talk {
position: relative;
z-index: 2;
color: #fff;
font-weight: 600;
padding: 0 36px;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.95rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 260px;
height: 54px;
border-radius: 9999px;
background: linear-gradient(135deg, #2a1810 0%, #150c08 100%);
border: 1.5px solid rgba(255, 170, 0, 0.45);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 15px rgba(255, 170, 0, 0.15);
line-height: 1;
}
.btn-talk:hover {
transform: translateY(-2px);
border-color: #ffaa00;
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.5), 0 0 25px rgba(255, 170, 0, 0.35);
background: linear-gradient(135deg, #382112 0%, #1e120a 100%);
}
/* Scrollbar styles */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-glow);
}
/* Blog Carousel */
.blog-carousel::-webkit-scrollbar,
.services-carousel::-webkit-scrollbar {
display: none;
}
.blog-card:hover .blog-card-img {
transform: scale(1.05);
}
.blog-card:hover .blog-read-more {
color: #ffaa00;
}
#contactus {
scroll-margin-top: 88px;
}
@media (max-width: 1023px) {
#contactus {
scroll-margin-top: 72px;
}
.btn-sparkles {
display: none;
}
.btn-sparkle-wrapper {
width: 100%;
}
.btn-talk {
min-width: unset;
width: 100%;
height: 48px;
font-size: 0.88rem;
padding: 0 1.25rem;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-on-scroll {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { ContentProvider } from './context/ContentContext.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ContentProvider>
<App />
</ContentProvider>
</StrictMode>,
)
+26
View File
@@ -0,0 +1,26 @@
import { StrictMode, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import '../index.css';
import './preview.css';
import App from '../App.jsx';
import { ContentProvider } from '../context/ContentContext.jsx';
import { PreviewContext } from '../context/PreviewContext.jsx';
function PreviewShell() {
useEffect(() => {
document.body.classList.add('admin-preview-embed');
return () => document.body.classList.remove('admin-preview-embed');
}, []);
return <App previewMode />;
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<PreviewContext.Provider value>
<ContentProvider previewMode>
<PreviewShell />
</ContentProvider>
</PreviewContext.Provider>
</StrictMode>,
);
+14
View File
@@ -0,0 +1,14 @@
body.admin-preview-embed {
overflow-x: hidden;
}
body.admin-preview-embed [data-preview-hide] {
display: none !important;
}
/* Keep CSS-driven animations active inside the admin preview iframe */
body.admin-preview-embed *,
body.admin-preview-embed *::before,
body.admin-preview-embed *::after {
animation-play-state: running !important;
}
+27
View File
@@ -0,0 +1,27 @@
# Tasks - Why Cavin Infotech Grid Alignment & Featured Gallery & Products Suite
- [x] Implement the 4-column, 2-row CSS Grid layout in `src/App.jsx`
- [x] Update Grid container styles (`gridTemplateColumns`, `gridTemplateRows`)
- [x] Adjust grid coordinates for all cards and images
- [x] Add warm amber radial glow to "Industry Expertise" and "Secure & Reliable" cards
- [x] Apply reversed overlay gradient to "Outcome Focused" card
- [x] Reduce mobile font sizes and apply styles to mobile layout
- [x] Create and integrate the premium "Featured Gallery" section
- [x] Implement reusable component `src/components/FeaturedGallery.jsx`
- [x] Design expanded/collapsed card states with Framer Motion layout transitions
- [x] Add hover tracking for active index retention
- [x] Incorporate category labels, read times, animated progress lines, and Read More buttons
- [x] Setup mobile fallback stacked view
- [x] Import and place the gallery after Why Cavin Infotech section
- [x] Redesign "Our Products Suite" section to light contrast theme
- [x] Change section background to off-white (`#f8fafc`)
- [x] Restructure products to side-by-side grid layout
- [x] Design card backgrounds with a soft blush-pink `#F6F2F5` and light-gray gradient
- [x] Style card borders with a thick 6px stroke in muted lavender-gray
- [x] Implement fading micro-dot pattern background texture rising from the bottom
- [x] Style light cards with subtle case-by-case brand gradients (purple/pink, green/mint, gold/amber)
- [x] Implement visual mock dashboard previews at the bottom of cards
- [x] Add percentage/stat overlays (OEE Improvement, Downtime, AI Assistance, Workflow Automation, HR Operations)
- [x] Build centered dark CTA button ("View All Products") with gold glow effect and hover scale transitions
- [x] Verify build compiles correctly
- [x] Test layout responsiveness and verify visual alignment in the browser
+29
View File
@@ -0,0 +1,29 @@
# Walkthrough - Website Updates
We have completed the redesign of the **Our Products Suite** section, the **Why Cavin Infotech** section grid, and created the premium **Featured Gallery** section.
## Key Accomplishments
### 1. Light Contrast Products Suite Premium CSS Cards & Screenshots
- **Prodmax Mockup Image**: Copied the uploaded Prodmax dashboard mockup image to `/assets/prodmax_dashboard.png`.
- **Card Placement**: Replaced the custom CSS charts in the **Prodmax** card with a container rendering the dashboard image. Set `left: '35%'` and `right: '-20px'` to position the mockup slightly off-screen on the right, successfully leaving elegant negative space on the left side of the card for headings, descriptions, tags, and overlays.
- **Sophisticated Card Colors**: Implemented a modern, Apple/Stripe-inspired CSS background consisting of a gentle vertical gradient from light lavender-gray (`#FAF9FA`) at the top to soft blush pink (`#F6F2F5`) at the bottom.
- **Fading Micro-Dot Pattern Texture**: Built a native, high-performance CSS micro-dot background texture (`radial-gradient(#E0D2DC 1.5px, transparent 1.5px)`) placed inside each card, fading out smoothly from the bottom up to 65% height using a linear mask overlay.
- **Thick Lavender-Gray Border**: Added a soft, premium 6px border in a muted lavender-gray stroke (`#EAE3E8`) with large rounded corners (`28px` radius).
- **Subtle Branding Radial Glows**: Kept product-specific branding intact by overlaying soft, low-contrast radial glows (purple, green, and amber) in the top-right corners of each card.
- **Warm Gold Hover Glows**: Enriched hover interactions by scaling cards up, shifting them vertically, transforming their borders to a warm gold accent (`#ffaa00`), and casting a soft gold ambient shadow.
- **Light Contrast Background**: Converted the section to `#f8fafc` (off-white) to visually separate it from other dark-themed sections.
- **Stylized Dashboard Previews & Stats**: Retained the HTML-based dashboard mockups and floating glassmorphic statistics badges.
- **Dark Gold-Glowing CTA**: Added a dark-themed (`#0f172a`) centered button at the bottom: "View All Products" with a warm gold glow.
### 2. Featured Gallery Section Placement & Restructuring
- **Placement**: Moved the Featured Gallery section to sit immediately **after** the "Why Cavin Infotech" section and before the "Products Suite" section.
- **Reusable Component**: Built [FeaturedGallery.jsx](file:///Users/vishvav-120049/Downloads/Cavin%20Infotech%20Website%20Project/src/components/FeaturedGallery.jsx) containing 6 customized cards that expand smoothly on hover.
### 3. Why Cavin Infotech Section Restructuring
- **Mockup-Matched 4-Column, 2-Row Grid**: Restructured layout to place "Secure & Reliable" card and the boardroom image side-by-side under "Industry Expertise" card.
- **Equal-Height Layout Constraints**: Set `height: '100%'` constraints on all grid cards and image wrapper containers.
## Verification
- Vite development server hot module replacement loaded all updates successfully.
- Verified horizontal row boundary alignments and responsive view transitions.