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>
216 lines
6.0 KiB
TypeScript
216 lines
6.0 KiB
TypeScript
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;
|