first commit
This commit is contained in:
+184
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 "Add item" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const PreviewContext = createContext(false);
|
||||
|
||||
export function usePreviewMode() {
|
||||
return useContext(PreviewContext);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user