Files
smoothschedule/frontend/src/puck/components/marketing/Hero.tsx
poduck 2bfa01e0d4 Add image picker to site builder components and fix media gallery bugs
- Add ImagePickerField to all site builder components with image URLs:
  - Marketing: Hero, SplitContent, LogoCloud, GalleryGrid, Testimonials,
    ContentBlocks, Header, Footer
  - Email: EmailImage, EmailHeader
- Fix media gallery issues:
  - Change API endpoint from /media/ to /media-files/ to avoid URL conflict
  - Fix album file_count annotation conflict with model property
  - Fix image URLs to use absolute paths for cross-domain access
  - Add clipboard fallback for non-HTTPS copy URL
- Show permission slugs in plan feature editor
- Fix branding settings access for tenants with custom_branding feature
- Fix EntitlementService method call in StorageQuotaService

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 18:21:52 -05:00

268 lines
7.8 KiB
TypeScript

import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface HeroCtaButton {
text: string;
href: string;
}
export interface HeroMedia {
type: 'image' | 'none';
src?: string;
alt?: string;
}
export interface HeroProps extends DesignControlsProps {
headline: string;
subheadline: string;
primaryCta: HeroCtaButton;
secondaryCta?: HeroCtaButton;
media?: HeroMedia;
badge?: string;
variant: 'centered' | 'split' | 'minimal';
fullWidth?: boolean;
}
const HeroRender: React.FC<HeroProps> = (props) => {
const {
headline,
subheadline,
primaryCta,
secondaryCta,
media,
badge,
variant,
fullWidth,
...designControls
} = props;
const applied = applyDesignControls(designControls);
const isGradient = designControls.backgroundVariant === 'gradient';
const isDark = isGradient || designControls.backgroundVariant === 'dark';
const isSplit = variant === 'split';
const isMinimal = variant === 'minimal';
const containerClass = fullWidth
? 'w-full px-4 sm:px-6 lg:px-8'
: `${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`;
return (
<section
id={designControls.anchorId || undefined}
className={`relative overflow-hidden ${applied.className}`}
style={applied.style}
>
{/* Overlay for background images */}
{designControls.backgroundImage && designControls.overlayStrength && (
<div
className="absolute inset-0 bg-black pointer-events-none"
style={{ opacity: designControls.overlayStrength / 100 }}
/>
)}
<div className={`relative ${containerClass} py-16 sm:py-24 lg:py-32`}>
<div
className={
isSplit
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
: 'text-center max-w-4xl mx-auto'
}
>
<div>
{/* Badge */}
{badge && !isMinimal && (
<div className={`mb-6 ${isSplit ? '' : ''}`}>
<span
className={`inline-block px-4 py-1.5 rounded-full text-sm font-medium ${
isDark
? 'bg-white/10 text-white border border-white/20'
: 'bg-primary-100 text-primary-700'
}`}
>
{badge}
</span>
</div>
)}
{/* Headline */}
<h1
className={`text-4xl sm:text-5xl ${isMinimal ? '' : 'lg:text-6xl'} font-bold tracking-tight ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{headline}
</h1>
{/* Subheadline */}
<p
className={`mt-6 text-lg sm:text-xl ${isSplit ? '' : 'max-w-2xl mx-auto'} ${
isDark ? 'text-white/80' : 'text-neutral-600'
}`}
>
{subheadline}
</p>
{/* CTAs */}
{(primaryCta?.text || secondaryCta?.text) && (
<div
className={`mt-10 flex flex-col sm:flex-row gap-4 ${
isSplit ? '' : 'items-center justify-center'
}`}
>
{primaryCta?.text && (
<a
href={primaryCta.href || '#'}
className={`w-full sm:w-auto px-8 py-3 rounded-lg font-semibold text-center transition-colors ${
isDark
? 'bg-white text-neutral-900 hover:bg-neutral-100'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{primaryCta.text}
</a>
)}
{secondaryCta?.text && (
<a
href={secondaryCta.href || '#'}
className={`w-full sm:w-auto px-8 py-3 rounded-lg font-semibold text-center transition-colors ${
isDark
? 'bg-transparent text-white border border-white/30 hover:bg-white/10'
: 'bg-transparent text-neutral-700 border border-neutral-300 hover:bg-neutral-50'
}`}
>
{secondaryCta.text}
</a>
)}
</div>
)}
</div>
{/* Media */}
{media?.type === 'image' && media.src && (
<div className={isSplit ? '' : 'mt-16'}>
<div className={`relative ${isSplit ? '' : 'mx-auto max-w-5xl'}`}>
<div
className={`rounded-xl overflow-hidden shadow-2xl ${
isDark ? 'ring-1 ring-white/10' : 'ring-1 ring-neutral-200'
}`}
>
<img
src={media.src}
alt={media.alt || 'Hero image'}
className="w-full h-auto"
/>
</div>
</div>
</div>
)}
</div>
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const Hero: ComponentConfig<HeroProps> = {
label: 'Hero',
fields: {
headline: {
type: 'text',
label: 'Headline',
},
subheadline: {
type: 'textarea',
label: 'Subheadline',
},
primaryCta: {
type: 'object',
label: 'Primary Button',
objectFields: {
text: { type: 'text', label: 'Button Text' },
href: { type: 'text', label: 'Button URL' },
},
},
secondaryCta: {
type: 'object',
label: 'Secondary Button (optional)',
objectFields: {
text: { type: 'text', label: 'Button Text' },
href: { type: 'text', label: 'Button URL' },
},
},
media: {
type: 'object',
label: 'Media',
objectFields: {
type: {
type: 'select',
label: 'Type',
options: [
{ label: 'None', value: 'none' },
{ label: 'Image', value: 'image' },
],
},
src: { ...imagePickerField, label: 'Image' },
alt: { type: 'text', label: 'Alt Text' },
},
},
badge: {
type: 'text',
label: 'Badge Text (optional)',
},
variant: {
type: 'select',
label: 'Layout Variant',
options: [
{ label: 'Centered', value: 'centered' },
{ label: 'Split (text + media)', value: 'split' },
{ label: 'Minimal', value: 'minimal' },
],
},
fullWidth: {
type: 'radio',
label: 'Full Width',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
backgroundImage: designControlFields.backgroundImage,
overlayStrength: designControlFields.overlayStrength,
contentMaxWidth: designControlFields.contentMaxWidth,
alignment: designControlFields.alignment,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
headline: 'Your headline goes here',
subheadline: 'Add a compelling subheadline that describes what you offer.',
primaryCta: {
text: 'Get Started',
href: '#',
},
variant: 'centered',
fullWidth: false,
backgroundVariant: 'none',
padding: 'xl',
contentMaxWidth: 'wide',
alignment: 'center',
},
render: HeroRender,
};
export default Hero;