2026-04-05 18:31:16 -07:00
import React , { CSSProperties } from 'react' ;
import { useNode , UserComponent } from '@craftjs/core' ;
import { cssPropsToString } from '../../utils/style-helpers' ;
2026-05-25 12:43:28 -07:00
import { AnchorIdField } from '../../ui/AnchorIdField' ;
2026-07-12 11:49:10 -07:00
import { escapeHtml , escapeAttr } from '../../utils/escape' ;
2026-04-05 18:31:16 -07:00
interface FeatureItem {
title : string ;
description : string ;
icon : string ;
2026-07-06 17:53:22 -07:00
mediaType ?: 'icon' | 'image' ; // default behaves as 'icon' when undefined
image? : string ;
imageAlt? : string ;
buttonText? : string ;
buttonUrl? : string ;
}
/* ---------- Image upload helper (same as Navbar/ImageBlock) ---------- */
async function uploadToWhp ( file : File ) : Promise < string | null > {
const cfg = ( window as any ). WHP_CONFIG ;
if ( ! cfg ) return URL . createObjectURL ( file );
const formData = new FormData ();
formData . append ( 'file' , file );
try {
const resp = await fetch ( ` ${ cfg . apiUrl } ?action=upload_asset&site_id= ${ cfg . siteId } ` , {
method : 'POST' ,
headers : { 'X-CSRF-Token' : cfg . csrfToken },
body : formData ,
});
const data = await resp . json ();
if ( data . success && data . url ) return data . url ;
return null ;
} catch { return null ; }
2026-04-05 18:31:16 -07:00
}
interface FeaturesGridProps {
features? : FeatureItem [];
style? : CSSProperties ;
2026-05-25 12:43:28 -07:00
anchorId? : string ;
2026-04-05 18:31:16 -07:00
}
2026-07-07 14:16:14 -07:00
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
// the guided panel's generic array editor (which derives fields from the first
// item's keys) exposes inputs for them. An image renders whenever `image` is set.
2026-04-05 18:31:16 -07:00
const defaultFeatures : FeatureItem [] = [
2026-07-07 14:16:14 -07:00
{ title : 'Fast & Reliable' , description : 'Built for performance with optimized loading and rock-solid uptime.' , icon : '⚡' , image : '' , imageAlt : '' , buttonText : '' , buttonUrl : '' },
{ title : 'Easy to Use' , description : 'Intuitive drag-and-drop interface that anyone can master in minutes.' , icon : '✨' , image : '' , imageAlt : '' , buttonText : '' , buttonUrl : '' },
{ title : 'Fully Responsive' , description : 'Looks great on every device, from phones to ultrawide monitors.' , icon : '📱' , image : '' , imageAlt : '' , buttonText : '' , buttonUrl : '' },
2026-04-05 18:31:16 -07:00
];
export const FeaturesGrid : UserComponent < FeaturesGridProps > = ({
features = defaultFeatures ,
style = {},
2026-05-25 12:43:28 -07:00
anchorId ,
2026-04-05 18:31:16 -07:00
}) => {
const {
connectors : { connect , drag },
selected ,
} = useNode (( node ) => ({
selected : node.events.selected ,
}));
return (
< section
ref = {( ref : HTMLElement | null ) : void => { if ( ref ) connect ( drag ( ref )); }}
2026-05-25 12:43:28 -07:00
id = { anchorId || undefined }
2026-04-05 18:31:16 -07:00
style = {{
padding : '80px 20px' ,
backgroundColor : '#ffffff' ,
outline : selected ? '2px solid #3b82f6' : 'none' ,
... style ,
}}
>
< div style = {{ maxWidth : '1100px' , margin : '0 auto' , display : 'grid' , gridTemplateColumns : 'repeat(3, 1fr)' , gap : '32px' }}>
{( Array . isArray ( features ) ? features : []). map (( feat , i ) => (
< div
key = { i }
style = {{
textAlign : 'center' ,
padding : '32px 24px' ,
borderRadius : '12px' ,
backgroundColor : '#f8fafc' ,
border : '1px solid #e2e8f0' ,
}}
>
2026-07-07 14:16:14 -07:00
{ feat . image ? (
2026-07-06 17:53:22 -07:00
< img
src = { feat . image }
alt = { feat . imageAlt || feat . title || '' }
style = {{ maxWidth : '100%' , height : 'auto' , marginBottom : '16px' , borderRadius : '8px' }}
/>
) : (
< div style = {{ fontSize : '36px' , marginBottom : '16px' }}>{ feat . icon }</ div >
)}
2026-04-05 18:31:16 -07:00
< h3 style = {{ fontSize : '20px' , fontWeight : '600' , color : '#18181b' , marginBottom : '8px' }}>{ feat . title }</ h3 >
< p style = {{ fontSize : '14px' , color : '#64748b' , lineHeight : '1.6' }}>{ feat . description }</ p >
2026-07-06 17:53:22 -07:00
{ feat . buttonText ? (
< a
href = { feat . buttonUrl || '#' }
onClick = {( e ) => e . preventDefault ()}
style = {{ display : 'inline-block' , marginTop : '16px' , padding : '10px 24px' , background : '#3b82f6' , color : '#fff' , borderRadius : '8px' , textDecoration : 'none' , fontSize : '14px' , fontWeight : 600 }}
>
{ feat . buttonText }
</ a >
) : null }
2026-04-05 18:31:16 -07:00
</ div >
))}
</ div >
</ section >
);
};
/* ---------- Settings panel ---------- */
const FeaturesGridSettings : React.FC = () => {
const { actions : { setProp }, props } = useNode (( node ) => ({
props : node.data.props as FeaturesGridProps ,
}));
const features = props . features || defaultFeatures ;
const inputStyle : CSSProperties = {
width : '100%' , padding : '3px 6px' , background : '#27272a' , color : '#e4e4e7' ,
border : '1px solid #3f3f46' , borderRadius : 4 , fontSize : 11 ,
};
const updateFeature = ( index : number , field : keyof FeatureItem , value : string ) => {
setProp (( p : FeaturesGridProps ) => {
const updated = [...( p . features || defaultFeatures )];
updated [ index ] = { ... updated [ index ], [ field ] : value };
p . features = updated ;
});
};
2026-07-06 17:53:22 -07:00
const handleImageUpload = async ( index : number , file : File ) => {
const url = await uploadToWhp ( file );
if ( url ) updateFeature ( index , 'image' , url );
};
2026-04-05 18:31:16 -07:00
const addFeature = () => {
setProp (( p : FeaturesGridProps ) => {
2026-07-07 14:16:14 -07:00
p . features = [...( p . features || defaultFeatures ), { title : 'New Feature' , description : 'Describe this feature.' , icon : '🔧' , image : '' , imageAlt : '' , buttonText : '' , buttonUrl : '' }];
2026-04-05 18:31:16 -07:00
});
};
const removeFeature = ( index : number ) => {
setProp (( p : FeaturesGridProps ) => {
const updated = [...( p . features || defaultFeatures )];
updated . splice ( index , 1 );
p . features = updated ;
});
};
const bgPresets = [ '#ffffff' , '#f8fafc' , '#f1f5f9' , '#18181b' , '#0f172a' ];
return (
< div style = {{ padding : '12px' , display : 'flex' , flexDirection : 'column' , gap : '14px' }}>
2026-05-25 12:43:28 -07:00
< AnchorIdField />
2026-04-05 18:31:16 -07:00
< div >
< label style = {{ fontSize : 11 , color : '#a1a1aa' , display : 'block' , marginBottom : 6 }}> Background </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{ bgPresets . map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : FeaturesGridProps ) => { p . style = { ... p . style , backgroundColor : c }; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
< div >
< label style = {{ fontSize : 11 , color : '#a1a1aa' , display : 'block' , marginBottom : 6 }}> Features </ label >
< div style = {{ display : 'flex' , flexDirection : 'column' , gap : 8 }}>
{( Array . isArray ( features ) ? features : []). map (( feat , i ) => (
< div key = { i } style = {{ background : '#1e1e22' , borderRadius : 6 , padding : 8 , display : 'flex' , flexDirection : 'column' , gap : 4 }}>
< div style = {{ display : 'flex' , gap : 4 }}>
< input type = "text" value = { feat . icon } onChange = {( e ) => updateFeature ( i , 'icon' , e . target . value )} placeholder = "Icon" style = {{ ... inputStyle , width : 40 , flex : 'none' , textAlign : 'center' }} />
< input type = "text" value = { feat . title } onChange = {( e ) => updateFeature ( i , 'title' , e . target . value )} placeholder = "Title" style = {{ ... inputStyle , flex : 1 }} />
< button
onClick = {() => removeFeature ( i )}
style = {{ padding : '2px 6px' , fontSize : 11 , background : '#ef4444' , color : '#fff' , border : 'none' , borderRadius : 4 , cursor : 'pointer' }}
>
X
</ button >
</ div >
< textarea
value = { feat . description }
onChange = {( e ) => updateFeature ( i , 'description' , e . target . value )}
placeholder = "Description"
rows = { 2 }
style = {{ ... inputStyle , resize : 'vertical' }}
/>
2026-07-06 17:53:22 -07:00
{ /* Icon / Image toggle */ }
< div style = {{ display : 'flex' , gap : 4 }}>
{([ 'icon' , 'image' ] as const ). map (( mt ) => {
const active = ( feat . mediaType || 'icon' ) === mt ;
return (
< button
key = { mt }
onClick = {() => updateFeature ( i , 'mediaType' , mt )}
style = {{
flex : 1 , padding : '3px 6px' , fontSize : 11 , cursor : 'pointer' ,
background : active ? '#3b82f6' : '#27272a' ,
color : active ? '#fff' : '#e4e4e7' ,
border : '1px solid #3f3f46' , borderRadius : 4 ,
}}
>
{ mt === 'icon' ? 'Icon' : 'Image' }
</ button >
);
})}
</ div >
{( feat . mediaType || 'icon' ) === 'image' ? (
<>
< div style = {{ display : 'flex' , gap : 4 }}>
< label
style = {{ padding : '3px 6px' , fontSize : 11 , cursor : 'pointer' , background : '#27272a' , color : '#e4e4e7' , border : '1px solid #3f3f46' , borderRadius : 4 , flex : 'none' , whiteSpace : 'nowrap' }}
>
Upload
< input
type = "file"
accept = "image/*"
style = {{ display : 'none' }}
onChange = {( e ) => { const f = e . target . files ? .[ 0 ]; if ( f ) void handleImageUpload ( i , f ); }}
/>
</ label >
< input type = "text" value = { feat . image || '' } onChange = {( e ) => updateFeature ( i , 'image' , e . target . value )} placeholder = "Image URL" style = {{ ... inputStyle , flex : 1 }} />
</ div >
< input type = "text" value = { feat . imageAlt || '' } onChange = {( e ) => updateFeature ( i , 'imageAlt' , e . target . value )} placeholder = "Alt text (optional)" style = { inputStyle } />
</>
) : null }
{ /* Button (optional) */ }
< label style = {{ fontSize : 11 , color : '#a1a1aa' , display : 'block' , marginTop : 2 }}> Button ( optional )</ label >
< input type = "text" value = { feat . buttonText || '' } onChange = {( e ) => updateFeature ( i , 'buttonText' , e . target . value )} placeholder = "Button text" style = { inputStyle } />
< input type = "text" value = { feat . buttonUrl || '' } onChange = {( e ) => updateFeature ( i , 'buttonUrl' , e . target . value )} placeholder = "https://... or /page" style = { inputStyle } />
2026-04-05 18:31:16 -07:00
</ div >
))}
</ div >
< button
onClick = { addFeature }
style = {{ marginTop : 6 , width : '100%' , padding : '6px' , fontSize : 11 , background : '#27272a' , color : '#e4e4e7' , border : '1px solid #3f3f46' , borderRadius : 4 , cursor : 'pointer' }}
>
+ Add Feature
</ button >
</ div >
</ div >
);
};
/* ---------- Craft config ---------- */
FeaturesGrid . craft = {
displayName : 'Features Grid' ,
props : {
features : defaultFeatures ,
style : { backgroundColor : '#ffffff' },
2026-05-25 12:43:28 -07:00
anchorId : '' ,
2026-04-05 18:31:16 -07:00
},
rules : {
canDrag : () => true ,
canMoveIn : () => false ,
canMoveOut : () => true ,
},
related : {
settings : FeaturesGridSettings ,
},
};
/* ---------- HTML export ---------- */
( FeaturesGrid as any ). toHtml = ( props : FeaturesGridProps , _childrenHtml : string ) => {
const sectionStyle = cssPropsToString ({
padding : '80px 20px' ,
... props . style ,
});
2026-07-12 11:49:10 -07:00
const idAttr = props . anchorId ? ` id=" ${ escapeAttr ( props . anchorId ) } "` : '' ;
2026-04-05 18:31:16 -07:00
const cards = ( props . features || defaultFeatures ). map (( feat ) => {
2026-07-07 14:16:14 -07:00
const media = feat . image
2026-07-12 11:49:10 -07:00
? `<img src=" ${ escapeAttr ( feat . image ) } " alt=" ${ escapeAttr ( feat . imageAlt || feat . title || '' ) } " style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
: `<div style="font-size:36px;margin-bottom:16px"> ${ escapeHtml ( feat . icon ) } </div>` ;
2026-07-06 17:53:22 -07:00
const button = feat . buttonText
2026-07-12 11:49:10 -07:00
? ` \ n <a href=" ${ escapeAttr ( feat . buttonUrl || '#' ) } " style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600"> ${ escapeHtml ( feat . buttonText ) } </a>`
2026-07-06 17:53:22 -07:00
: '' ;
2026-04-05 18:31:16 -07:00
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
2026-07-06 17:53:22 -07:00
${ media }
2026-07-12 11:49:10 -07:00
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px"> ${ escapeHtml ( feat . title ) } </h3>
<p style="font-size:14px;color:#64748b;line-height:1.6"> ${ escapeHtml ( feat . description ) } </p> ${ button }
2026-04-05 18:31:16 -07:00
</div>` ;
}). join ( '\n ' );
return {
2026-05-25 12:43:28 -07:00
html : `<section ${ idAttr }${ sectionStyle ? ` style=" ${ sectionStyle } "` : '' } >
2026-04-05 18:31:16 -07:00
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
${ cards }
</div>
</section>` ,
};
};