Add media gallery with album organization and Puck integration
Backend: - Add Album and MediaFile models for tenant-scoped media storage - Add TenantStorageUsage model for per-tenant storage quota tracking - Create StorageQuotaService with EntitlementService integration - Add AlbumViewSet, MediaFileViewSet with bulk operations - Add StorageUsageView for quota monitoring Frontend: - Create MediaGalleryPage with album management and file upload - Add drag-and-drop upload with storage quota validation - Create ImagePickerField custom Puck field for gallery integration - Update Image, Testimonial components to use ImagePicker - Add background image picker to Puck design controls - Add gallery to sidebar navigation Also includes: - Puck marketing components (Hero, SplitContent, etc.) - Enhanced ContactForm and BusinessHours components - Platform login page improvements - Site builder draft/preview enhancements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
215
frontend/src/puck/components/contact/AddressBlock.tsx
Normal file
215
frontend/src/puck/components/contact/AddressBlock.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import { MapPin, Phone, Mail, Building2 } from 'lucide-react';
|
||||
|
||||
export interface AddressBlockProps {
|
||||
businessName?: string;
|
||||
address?: string;
|
||||
address2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
showIcons: boolean;
|
||||
layout: 'vertical' | 'horizontal';
|
||||
alignment: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
const formatPhone = (phone: string): string => {
|
||||
// Remove all non-digits
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
|
||||
// Format as (XXX) XXX-XXXX if 10 digits
|
||||
if (digits.length === 10) {
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
// Return original if not 10 digits
|
||||
return phone;
|
||||
};
|
||||
|
||||
const AddressBlockRender: React.FC<AddressBlockProps> = ({
|
||||
businessName,
|
||||
address,
|
||||
address2,
|
||||
city,
|
||||
state,
|
||||
zip,
|
||||
phone,
|
||||
email,
|
||||
showIcons = true,
|
||||
layout = 'vertical',
|
||||
alignment = 'left',
|
||||
}) => {
|
||||
const alignmentClasses = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
};
|
||||
|
||||
const justifyClasses = {
|
||||
left: 'justify-start',
|
||||
center: 'justify-center',
|
||||
right: 'justify-end',
|
||||
};
|
||||
|
||||
// Build full address string
|
||||
const addressParts = [];
|
||||
if (address) addressParts.push(address);
|
||||
if (address2) addressParts.push(address2);
|
||||
|
||||
const cityStateZip = [city, state].filter(Boolean).join(', ');
|
||||
const fullCityLine = [cityStateZip, zip].filter(Boolean).join(' ');
|
||||
if (fullCityLine) addressParts.push(fullCityLine);
|
||||
|
||||
const fullAddress = addressParts.join(', ');
|
||||
|
||||
const items = [
|
||||
{ icon: Building2, value: businessName, href: null },
|
||||
{ icon: MapPin, value: fullAddress, href: fullAddress ? `https://maps.google.com/?q=${encodeURIComponent(fullAddress)}` : null },
|
||||
{ icon: Phone, value: phone ? formatPhone(phone) : null, href: phone ? `tel:${phone.replace(/\D/g, '')}` : null },
|
||||
{ icon: Mail, value: email, href: email ? `mailto:${email}` : null },
|
||||
].filter(item => item.value);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center text-gray-500">
|
||||
Add your business contact information
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === 'horizontal') {
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6`}>
|
||||
<div className={`flex flex-wrap gap-6 ${justifyClasses[alignment]}`}>
|
||||
{items.map(({ icon: Icon, value, href }, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{showIcons && (
|
||||
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0" />
|
||||
)}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target={href.startsWith('https') ? '_blank' : undefined}
|
||||
rel={href.startsWith('https') ? 'noopener noreferrer' : undefined}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-900 dark:text-white font-medium">{value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 ${alignmentClasses[alignment]}`}>
|
||||
<div className={`space-y-4 ${alignment === 'center' ? 'flex flex-col items-center' : ''}`}>
|
||||
{items.map(({ icon: Icon, value, href }, index) => (
|
||||
<div key={index} className={`flex items-start gap-3 ${justifyClasses[alignment]}`}>
|
||||
{showIcons && (
|
||||
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target={href.startsWith('https') ? '_blank' : undefined}
|
||||
rel={href.startsWith('https') ? 'noopener noreferrer' : undefined}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-900 dark:text-white font-semibold text-lg">{value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddressBlock: ComponentConfig<AddressBlockProps> = {
|
||||
label: 'Address Block',
|
||||
fields: {
|
||||
businessName: {
|
||||
type: 'text',
|
||||
label: 'Business Name',
|
||||
},
|
||||
address: {
|
||||
type: 'text',
|
||||
label: 'Street Address',
|
||||
},
|
||||
address2: {
|
||||
type: 'text',
|
||||
label: 'Address Line 2 (Suite, Unit, etc.)',
|
||||
},
|
||||
city: {
|
||||
type: 'text',
|
||||
label: 'City',
|
||||
},
|
||||
state: {
|
||||
type: 'text',
|
||||
label: 'State',
|
||||
},
|
||||
zip: {
|
||||
type: 'text',
|
||||
label: 'ZIP Code',
|
||||
},
|
||||
phone: {
|
||||
type: 'text',
|
||||
label: 'Phone Number',
|
||||
},
|
||||
email: {
|
||||
type: 'text',
|
||||
label: 'Email Address',
|
||||
},
|
||||
showIcons: {
|
||||
type: 'radio',
|
||||
label: 'Show Icons',
|
||||
options: [
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
type: 'select',
|
||||
label: 'Layout',
|
||||
options: [
|
||||
{ label: 'Vertical (Stacked)', value: 'vertical' },
|
||||
{ label: 'Horizontal (Inline)', value: 'horizontal' },
|
||||
],
|
||||
},
|
||||
alignment: {
|
||||
type: 'select',
|
||||
label: 'Alignment',
|
||||
options: [
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
businessName: '',
|
||||
address: '',
|
||||
address2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
showIcons: true,
|
||||
layout: 'vertical',
|
||||
alignment: 'left',
|
||||
},
|
||||
render: AddressBlockRender,
|
||||
};
|
||||
|
||||
export default AddressBlock;
|
||||
Reference in New Issue
Block a user