@aws505/sheetsite 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/dist/components/index.js +1696 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +1630 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/config/index.js +1840 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/index.mjs +1793 -0
- package/dist/config/index.mjs.map +1 -0
- package/dist/data/index.js +1296 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/index.mjs +1220 -0
- package/dist/data/index.mjs.map +1 -0
- package/dist/index.js +5433 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5285 -0
- package/dist/index.mjs.map +1 -0
- package/dist/seo/index.js +187 -0
- package/dist/seo/index.js.map +1 -0
- package/dist/seo/index.mjs +155 -0
- package/dist/seo/index.mjs.map +1 -0
- package/dist/theme/index.js +552 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/index.mjs +526 -0
- package/dist/theme/index.mjs.map +1 -0
- package/package.json +96 -0
- package/src/components/index.ts +41 -0
- package/src/components/layout/Footer.tsx +234 -0
- package/src/components/layout/Header.tsx +134 -0
- package/src/components/sections/FAQ.tsx +178 -0
- package/src/components/sections/Gallery.tsx +107 -0
- package/src/components/sections/Hero.tsx +202 -0
- package/src/components/sections/Hours.tsx +225 -0
- package/src/components/sections/Services.tsx +216 -0
- package/src/components/sections/Testimonials.tsx +184 -0
- package/src/components/ui/Button.tsx +158 -0
- package/src/components/ui/Card.tsx +162 -0
- package/src/components/ui/Icons.tsx +508 -0
- package/src/config/index.ts +207 -0
- package/src/config/presets/generic.ts +153 -0
- package/src/config/presets/home-kitchen.ts +154 -0
- package/src/config/presets/index.ts +708 -0
- package/src/config/presets/professional.ts +165 -0
- package/src/config/presets/repair.ts +160 -0
- package/src/config/presets/restaurant.ts +162 -0
- package/src/config/presets/salon.ts +178 -0
- package/src/config/presets/tailor.ts +159 -0
- package/src/config/types.ts +314 -0
- package/src/data/csv-parser.ts +154 -0
- package/src/data/defaults.ts +202 -0
- package/src/data/google-drive.ts +148 -0
- package/src/data/index.ts +535 -0
- package/src/data/sheets.ts +709 -0
- package/src/data/types.ts +379 -0
- package/src/seo/index.ts +272 -0
- package/src/theme/colors.ts +351 -0
- package/src/theme/index.ts +249 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testimonials Section Component
|
|
3
|
+
*
|
|
4
|
+
* Displays customer reviews and testimonials.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import type { Testimonial } from '../../data/types';
|
|
9
|
+
import { StarIcon } from '../ui/Icons';
|
|
10
|
+
|
|
11
|
+
export interface TestimonialsProps {
|
|
12
|
+
testimonials: Testimonial[];
|
|
13
|
+
title?: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
columns?: 1 | 2 | 3;
|
|
16
|
+
showRatings?: boolean;
|
|
17
|
+
variant?: 'cards' | 'quotes' | 'minimal';
|
|
18
|
+
limit?: number;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Testimonials section component.
|
|
24
|
+
*/
|
|
25
|
+
export function Testimonials({
|
|
26
|
+
testimonials,
|
|
27
|
+
title = 'What Our Customers Say',
|
|
28
|
+
subtitle,
|
|
29
|
+
columns = 3,
|
|
30
|
+
showRatings = true,
|
|
31
|
+
variant = 'cards',
|
|
32
|
+
limit,
|
|
33
|
+
className = '',
|
|
34
|
+
}: TestimonialsProps) {
|
|
35
|
+
const displayedTestimonials = limit ? testimonials.slice(0, limit) : testimonials;
|
|
36
|
+
|
|
37
|
+
const gridCols = {
|
|
38
|
+
1: 'max-w-2xl mx-auto',
|
|
39
|
+
2: 'md:grid-cols-2 max-w-4xl mx-auto',
|
|
40
|
+
3: 'md:grid-cols-2 lg:grid-cols-3',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<section className={`py-16 ${className}`}>
|
|
45
|
+
<div className="container mx-auto px-4">
|
|
46
|
+
{/* Header */}
|
|
47
|
+
<div className="text-center mb-12">
|
|
48
|
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
|
|
49
|
+
{subtitle && (
|
|
50
|
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{/* Testimonials Grid */}
|
|
55
|
+
<div className={`grid gap-6 ${gridCols[columns]}`}>
|
|
56
|
+
{displayedTestimonials.map((testimonial) => (
|
|
57
|
+
<TestimonialCard
|
|
58
|
+
key={testimonial.id || testimonial.name}
|
|
59
|
+
testimonial={testimonial}
|
|
60
|
+
showRating={showRatings}
|
|
61
|
+
variant={variant}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Individual testimonial card.
|
|
72
|
+
*/
|
|
73
|
+
function TestimonialCard({
|
|
74
|
+
testimonial,
|
|
75
|
+
showRating,
|
|
76
|
+
variant,
|
|
77
|
+
}: {
|
|
78
|
+
testimonial: Testimonial;
|
|
79
|
+
showRating: boolean;
|
|
80
|
+
variant: TestimonialsProps['variant'];
|
|
81
|
+
}) {
|
|
82
|
+
if (variant === 'quotes') {
|
|
83
|
+
return (
|
|
84
|
+
<div className="text-center">
|
|
85
|
+
<QuoteIcon className="w-10 h-10 text-primary-200 mx-auto mb-4" />
|
|
86
|
+
<blockquote className="text-lg text-gray-700 italic mb-4">
|
|
87
|
+
"{testimonial.quote}"
|
|
88
|
+
</blockquote>
|
|
89
|
+
{showRating && testimonial.rating && (
|
|
90
|
+
<StarRating rating={testimonial.rating} className="justify-center mb-2" />
|
|
91
|
+
)}
|
|
92
|
+
<div className="font-semibold text-gray-900">{testimonial.name}</div>
|
|
93
|
+
{testimonial.context && (
|
|
94
|
+
<div className="text-sm text-gray-500">{testimonial.context}</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (variant === 'minimal') {
|
|
101
|
+
return (
|
|
102
|
+
<div className="border-l-4 border-primary-500 pl-4">
|
|
103
|
+
<blockquote className="text-gray-700 mb-2">
|
|
104
|
+
"{testimonial.quote}"
|
|
105
|
+
</blockquote>
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<span className="font-semibold text-gray-900">{testimonial.name}</span>
|
|
108
|
+
{showRating && testimonial.rating && (
|
|
109
|
+
<StarRating rating={testimonial.rating} size={14} />
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Default: cards variant
|
|
117
|
+
return (
|
|
118
|
+
<div className="bg-white rounded-lg shadow p-6">
|
|
119
|
+
{showRating && testimonial.rating && (
|
|
120
|
+
<StarRating rating={testimonial.rating} className="mb-3" />
|
|
121
|
+
)}
|
|
122
|
+
<blockquote className="text-gray-700 mb-4">
|
|
123
|
+
"{testimonial.quote}"
|
|
124
|
+
</blockquote>
|
|
125
|
+
<div className="flex items-center">
|
|
126
|
+
{testimonial.imageUrl && (
|
|
127
|
+
<img
|
|
128
|
+
src={testimonial.imageUrl}
|
|
129
|
+
alt={testimonial.name}
|
|
130
|
+
className="w-10 h-10 rounded-full mr-3 object-cover"
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
<div>
|
|
134
|
+
<div className="font-semibold text-gray-900">{testimonial.name}</div>
|
|
135
|
+
{testimonial.context && (
|
|
136
|
+
<div className="text-sm text-gray-500">{testimonial.context}</div>
|
|
137
|
+
)}
|
|
138
|
+
{testimonial.source && (
|
|
139
|
+
<div className="text-xs text-gray-400">via {testimonial.source}</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Star rating component.
|
|
149
|
+
*/
|
|
150
|
+
function StarRating({
|
|
151
|
+
rating,
|
|
152
|
+
size = 18,
|
|
153
|
+
className = '',
|
|
154
|
+
}: {
|
|
155
|
+
rating: number;
|
|
156
|
+
size?: number;
|
|
157
|
+
className?: string;
|
|
158
|
+
}) {
|
|
159
|
+
return (
|
|
160
|
+
<div className={`flex ${className}`} aria-label={`${rating} out of 5 stars`}>
|
|
161
|
+
{[1, 2, 3, 4, 5].map((star) => (
|
|
162
|
+
<StarIcon
|
|
163
|
+
key={star}
|
|
164
|
+
size={size}
|
|
165
|
+
filled={star <= rating}
|
|
166
|
+
className={star <= rating ? 'text-yellow-400' : 'text-gray-300'}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Quote icon.
|
|
175
|
+
*/
|
|
176
|
+
function QuoteIcon({ className }: { className?: string }) {
|
|
177
|
+
return (
|
|
178
|
+
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
|
179
|
+
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
|
180
|
+
</svg>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default Testimonials;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button Component
|
|
3
|
+
*
|
|
4
|
+
* A versatile button component with multiple variants and sizes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
|
|
9
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
10
|
+
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link';
|
|
11
|
+
size?: 'sm' | 'md' | 'lg';
|
|
12
|
+
fullWidth?: boolean;
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
leftIcon?: React.ReactNode;
|
|
15
|
+
rightIcon?: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get CSS classes for button variants and sizes.
|
|
20
|
+
*/
|
|
21
|
+
function getButtonClasses(props: Pick<ButtonProps, 'variant' | 'size' | 'fullWidth' | 'disabled'>): string {
|
|
22
|
+
const { variant = 'primary', size = 'md', fullWidth, disabled } = props;
|
|
23
|
+
|
|
24
|
+
const baseClasses = [
|
|
25
|
+
'inline-flex items-center justify-center',
|
|
26
|
+
'font-medium',
|
|
27
|
+
'transition-colors duration-200',
|
|
28
|
+
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
|
29
|
+
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
|
30
|
+
fullWidth ? 'w-full' : '',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Variant classes
|
|
34
|
+
const variantClasses: Record<NonNullable<ButtonProps['variant']>, string> = {
|
|
35
|
+
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
|
36
|
+
secondary: 'bg-primary-100 text-primary-700 hover:bg-primary-200 focus:ring-primary-500',
|
|
37
|
+
outline: 'border-2 border-primary-600 text-primary-600 hover:bg-primary-50 focus:ring-primary-500',
|
|
38
|
+
ghost: 'text-primary-600 hover:bg-primary-100 focus:ring-primary-500',
|
|
39
|
+
link: 'text-primary-600 hover:underline focus:ring-primary-500 p-0',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Size classes
|
|
43
|
+
const sizeClasses: Record<NonNullable<ButtonProps['size']>, string> = {
|
|
44
|
+
sm: 'text-sm px-3 py-1.5 rounded',
|
|
45
|
+
md: 'text-base px-4 py-2 rounded-md',
|
|
46
|
+
lg: 'text-lg px-6 py-3 rounded-lg',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
...baseClasses,
|
|
51
|
+
variantClasses[variant],
|
|
52
|
+
variant !== 'link' ? sizeClasses[size] : '',
|
|
53
|
+
]
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join(' ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Button component with multiple variants and sizes.
|
|
60
|
+
*/
|
|
61
|
+
export function Button({
|
|
62
|
+
variant = 'primary',
|
|
63
|
+
size = 'md',
|
|
64
|
+
fullWidth = false,
|
|
65
|
+
loading = false,
|
|
66
|
+
leftIcon,
|
|
67
|
+
rightIcon,
|
|
68
|
+
children,
|
|
69
|
+
disabled,
|
|
70
|
+
className = '',
|
|
71
|
+
...props
|
|
72
|
+
}: ButtonProps) {
|
|
73
|
+
const classes = getButtonClasses({ variant, size, fullWidth, disabled: disabled || loading });
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
className={`${classes} ${className}`}
|
|
78
|
+
disabled={disabled || loading}
|
|
79
|
+
{...props}
|
|
80
|
+
>
|
|
81
|
+
{loading ? (
|
|
82
|
+
<LoadingSpinner size={size} />
|
|
83
|
+
) : (
|
|
84
|
+
<>
|
|
85
|
+
{leftIcon && <span className="mr-2">{leftIcon}</span>}
|
|
86
|
+
{children}
|
|
87
|
+
{rightIcon && <span className="ml-2">{rightIcon}</span>}
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</button>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Loading spinner for button loading state.
|
|
96
|
+
*/
|
|
97
|
+
function LoadingSpinner({ size }: { size: ButtonProps['size'] }) {
|
|
98
|
+
const sizeClasses = {
|
|
99
|
+
sm: 'w-4 h-4',
|
|
100
|
+
md: 'w-5 h-5',
|
|
101
|
+
lg: 'w-6 h-6',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<svg
|
|
106
|
+
className={`animate-spin ${sizeClasses[size || 'md']}`}
|
|
107
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
108
|
+
fill="none"
|
|
109
|
+
viewBox="0 0 24 24"
|
|
110
|
+
>
|
|
111
|
+
<circle
|
|
112
|
+
className="opacity-25"
|
|
113
|
+
cx="12"
|
|
114
|
+
cy="12"
|
|
115
|
+
r="10"
|
|
116
|
+
stroke="currentColor"
|
|
117
|
+
strokeWidth="4"
|
|
118
|
+
/>
|
|
119
|
+
<path
|
|
120
|
+
className="opacity-75"
|
|
121
|
+
fill="currentColor"
|
|
122
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
123
|
+
/>
|
|
124
|
+
</svg>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Link styled as a button.
|
|
130
|
+
*/
|
|
131
|
+
export interface ButtonLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
132
|
+
variant?: ButtonProps['variant'];
|
|
133
|
+
size?: ButtonProps['size'];
|
|
134
|
+
fullWidth?: boolean;
|
|
135
|
+
leftIcon?: React.ReactNode;
|
|
136
|
+
rightIcon?: React.ReactNode;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function ButtonLink({
|
|
140
|
+
variant = 'primary',
|
|
141
|
+
size = 'md',
|
|
142
|
+
fullWidth = false,
|
|
143
|
+
leftIcon,
|
|
144
|
+
rightIcon,
|
|
145
|
+
children,
|
|
146
|
+
className = '',
|
|
147
|
+
...props
|
|
148
|
+
}: ButtonLinkProps) {
|
|
149
|
+
const classes = getButtonClasses({ variant, size, fullWidth, disabled: false });
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<a className={`${classes} ${className}`} {...props}>
|
|
153
|
+
{leftIcon && <span className="mr-2">{leftIcon}</span>}
|
|
154
|
+
{children}
|
|
155
|
+
{rightIcon && <span className="ml-2">{rightIcon}</span>}
|
|
156
|
+
</a>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card Component
|
|
3
|
+
*
|
|
4
|
+
* A flexible card component for displaying content in a contained box.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
|
|
9
|
+
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
variant?: 'default' | 'elevated' | 'outlined' | 'filled';
|
|
11
|
+
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
12
|
+
hover?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get CSS classes for card variants.
|
|
17
|
+
*/
|
|
18
|
+
function getCardClasses(props: Pick<CardProps, 'variant' | 'padding' | 'hover'>): string {
|
|
19
|
+
const { variant = 'default', padding = 'md', hover = false } = props;
|
|
20
|
+
|
|
21
|
+
const baseClasses = ['rounded-lg', 'overflow-hidden'];
|
|
22
|
+
|
|
23
|
+
// Variant classes
|
|
24
|
+
const variantClasses: Record<NonNullable<CardProps['variant']>, string> = {
|
|
25
|
+
default: 'bg-white shadow',
|
|
26
|
+
elevated: 'bg-white shadow-lg',
|
|
27
|
+
outlined: 'bg-white border border-gray-200',
|
|
28
|
+
filled: 'bg-gray-50',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Padding classes
|
|
32
|
+
const paddingClasses: Record<NonNullable<CardProps['padding']>, string> = {
|
|
33
|
+
none: '',
|
|
34
|
+
sm: 'p-3',
|
|
35
|
+
md: 'p-5',
|
|
36
|
+
lg: 'p-8',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Hover effect
|
|
40
|
+
const hoverClasses = hover
|
|
41
|
+
? 'transition-all duration-200 hover:shadow-lg hover:-translate-y-1'
|
|
42
|
+
: '';
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
...baseClasses,
|
|
46
|
+
variantClasses[variant],
|
|
47
|
+
paddingClasses[padding],
|
|
48
|
+
hoverClasses,
|
|
49
|
+
]
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join(' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Card component for containing content.
|
|
56
|
+
*/
|
|
57
|
+
export function Card({
|
|
58
|
+
variant = 'default',
|
|
59
|
+
padding = 'md',
|
|
60
|
+
hover = false,
|
|
61
|
+
children,
|
|
62
|
+
className = '',
|
|
63
|
+
...props
|
|
64
|
+
}: CardProps) {
|
|
65
|
+
const classes = getCardClasses({ variant, padding, hover });
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={`${classes} ${className}`} {...props}>
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Card header section.
|
|
76
|
+
*/
|
|
77
|
+
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
78
|
+
title?: string;
|
|
79
|
+
subtitle?: string;
|
|
80
|
+
action?: React.ReactNode;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function CardHeader({
|
|
84
|
+
title,
|
|
85
|
+
subtitle,
|
|
86
|
+
action,
|
|
87
|
+
children,
|
|
88
|
+
className = '',
|
|
89
|
+
...props
|
|
90
|
+
}: CardHeaderProps) {
|
|
91
|
+
return (
|
|
92
|
+
<div className={`flex items-start justify-between ${className}`} {...props}>
|
|
93
|
+
<div>
|
|
94
|
+
{title && <h3 className="text-lg font-semibold text-gray-900">{title}</h3>}
|
|
95
|
+
{subtitle && <p className="mt-1 text-sm text-gray-500">{subtitle}</p>}
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
{action && <div className="flex-shrink-0 ml-4">{action}</div>}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Card body section.
|
|
105
|
+
*/
|
|
106
|
+
export function CardBody({
|
|
107
|
+
children,
|
|
108
|
+
className = '',
|
|
109
|
+
...props
|
|
110
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
111
|
+
return (
|
|
112
|
+
<div className={`mt-4 ${className}`} {...props}>
|
|
113
|
+
{children}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Card footer section.
|
|
120
|
+
*/
|
|
121
|
+
export function CardFooter({
|
|
122
|
+
children,
|
|
123
|
+
className = '',
|
|
124
|
+
...props
|
|
125
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
126
|
+
return (
|
|
127
|
+
<div className={`mt-4 pt-4 border-t border-gray-100 ${className}`} {...props}>
|
|
128
|
+
{children}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Card image section.
|
|
135
|
+
*/
|
|
136
|
+
export interface CardImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|
137
|
+
aspectRatio?: 'square' | 'video' | 'wide' | 'auto';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function CardImage({
|
|
141
|
+
aspectRatio = 'video',
|
|
142
|
+
className = '',
|
|
143
|
+
alt = '',
|
|
144
|
+
...props
|
|
145
|
+
}: CardImageProps) {
|
|
146
|
+
const aspectClasses: Record<NonNullable<CardImageProps['aspectRatio']>, string> = {
|
|
147
|
+
square: 'aspect-square',
|
|
148
|
+
video: 'aspect-video',
|
|
149
|
+
wide: 'aspect-[2/1]',
|
|
150
|
+
auto: '',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className={`-m-5 mb-4 ${aspectClasses[aspectRatio]} overflow-hidden`}>
|
|
155
|
+
<img
|
|
156
|
+
className={`w-full h-full object-cover ${className}`}
|
|
157
|
+
alt={alt}
|
|
158
|
+
{...props}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|