1use dioxus::prelude::*;
6
7use crate::atoms::{
8 Button, ButtonSize, ButtonVariant, Heading, HeadingLevel, Icon, IconColor, IconSize, Label,
9 TextSize,
10};
11use crate::molecules::{
12 Badge, BadgeVariant, Card, CardContent, CardFooter, CardFooterJustify, CardHeader, CardVariant,
13};
14
15#[derive(Props, Clone, PartialEq)]
20pub struct ActionCardProps {
21 pub title: String,
23 pub description: String,
25 pub action_label: String,
27 pub on_action: EventHandler<()>,
29 #[props(default)]
31 pub icon: Option<String>,
32 #[props(default)]
34 pub variant: CardVariant,
35 #[props(default)]
37 pub badge: Option<String>,
38}
39
40#[component]
41pub fn ActionCard(props: ActionCardProps) -> Element {
42 rsx! {
43 Card {
44 variant: props.variant,
45 full_width: true,
46
47 CardContent {
48 div {
49 style: "display: flex; flex-direction: column; gap: 12px;",
50
51 if let Some(icon) = props.icon.clone() {
52 div {
53 style: "width: 48px; height: 48px; background: #f1f5f9; border-radius: 12px; display: flex; align-items: center; justify-content: center;",
54
55 Icon {
56 name: icon,
57 size: IconSize::Large,
58 color: IconColor::Primary,
59 }
60 }
61 }
62
63 if let Some(badge) = props.badge.clone() {
64 Badge {
65 variant: BadgeVariant::Secondary,
66 "{badge}"
67 }
68 }
69
70 Heading {
71 level: HeadingLevel::H4,
72 "{props.title}"
73 }
74
75 p {
76 style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
77 "{props.description}"
78 }
79
80 Button {
81 variant: ButtonVariant::Primary,
82 full_width: true,
83 onclick: move |_| props.on_action.call(()),
84 "{props.action_label}"
85 }
86 }
87 }
88 }
89 }
90}
91
92#[derive(Props, Clone, PartialEq)]
97pub struct DualActionCardProps {
98 pub title: String,
100 pub description: String,
102 pub primary_label: String,
104 pub secondary_label: String,
106 pub on_primary: EventHandler<()>,
108 pub on_secondary: EventHandler<()>,
110 #[props(default)]
112 pub icon: Option<String>,
113}
114
115#[component]
116pub fn DualActionCard(props: DualActionCardProps) -> Element {
117 rsx! {
118 Card {
119 variant: CardVariant::Default,
120 full_width: true,
121
122 CardContent {
123 div {
124 style: "display: flex; flex-direction: column; gap: 12px;",
125
126 if let Some(icon) = props.icon.clone() {
127 div {
128 style: "width: 48px; height: 48px; background: #f1f5f9; border-radius: 12px; display: flex; align-items: center; justify-content: center;",
129
130 Icon {
131 name: icon,
132 size: IconSize::Large,
133 color: IconColor::Primary,
134 }
135 }
136 }
137
138 Heading {
139 level: HeadingLevel::H4,
140 "{props.title}"
141 }
142
143 p {
144 style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
145 "{props.description}"
146 }
147 }
148 }
149
150 CardFooter {
151 justify: CardFooterJustify::Between,
152
153 Button {
154 variant: ButtonVariant::Ghost,
155 onclick: move |_| props.on_secondary.call(()),
156 "{props.secondary_label}"
157 }
158
159 Button {
160 variant: ButtonVariant::Primary,
161 onclick: move |_| props.on_primary.call(()),
162 "{props.primary_label}"
163 }
164 }
165 }
166 }
167}
168
169#[derive(Props, Clone, PartialEq)]
174pub struct ImageCardProps {
175 pub image_url: String,
177 #[props(default)]
179 pub image_alt: String,
180 pub title: String,
182 pub description: String,
184 #[props(default)]
186 pub action_label: Option<String>,
187 #[props(default)]
189 pub on_action: Option<EventHandler<()>>,
190 #[props(default = "16/9".to_string())]
192 pub aspect_ratio: String,
193}
194
195#[component]
196pub fn ImageCard(props: ImageCardProps) -> Element {
197 rsx! {
198 Card {
199 variant: CardVariant::Default,
200 full_width: true,
201 padding: Some("0".to_string()),
202
203 div {
205 style: "width: 100%; aspect-ratio: {props.aspect_ratio}; overflow: hidden;",
206
207 img {
208 src: "{props.image_url}",
209 alt: "{props.image_alt}",
210 style: "width: 100%; height: 100%; object-fit: cover;",
211 }
212 }
213
214 CardContent {
215 div {
216 style: "display: flex; flex-direction: column; gap: 8px; padding: 4px 0;",
217
218 Heading {
219 level: HeadingLevel::H4,
220 "{props.title}"
221 }
222
223 p {
224 style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
225 "{props.description}"
226 }
227
228 if let Some(label) = props.action_label.clone() {
229 if let Some(handler) = props.on_action.clone() {
230 Button {
231 variant: ButtonVariant::Ghost,
232 onclick: move |_| handler.call(()),
233 "{label} →"
234 }
235 }
236 }
237 }
238 }
239 }
240 }
241}
242
243#[derive(Props, Clone, PartialEq)]
248pub struct ImageActionCardProps {
249 pub image_url: String,
251 pub title: String,
253 pub description: String,
255 pub primary_label: String,
257 pub secondary_label: String,
259 pub on_primary: EventHandler<()>,
261 pub on_secondary: EventHandler<()>,
263 #[props(default)]
265 pub badge: Option<String>,
266}
267
268#[component]
269pub fn ImageActionCard(props: ImageActionCardProps) -> Element {
270 rsx! {
271 Card {
272 variant: CardVariant::Default,
273 full_width: true,
274 padding: Some("0".to_string()),
275
276 div {
278 style: "position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden;",
279
280 img {
281 src: "{props.image_url}",
282 alt: "{props.title}",
283 style: "width: 100%; height: 100%; object-fit: cover;",
284 }
285
286 if let Some(badge) = props.badge.clone() {
287 div {
288 style: "position: absolute; top: 12px; left: 12px;",
289
290 Badge {
291 variant: BadgeVariant::Success,
292 "{badge}"
293 }
294 }
295 }
296 }
297
298 CardContent {
299 div {
300 style: "display: flex; flex-direction: column; gap: 12px; padding: 4px 0;",
301
302 Heading {
303 level: HeadingLevel::H4,
304 "{props.title}"
305 }
306
307 p {
308 style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
309 "{props.description}"
310 }
311 }
312 }
313
314 CardFooter {
315 justify: CardFooterJustify::Between,
316
317 Button {
318 variant: ButtonVariant::Secondary,
319 onclick: move |_| props.on_secondary.call(()),
320 "{props.secondary_label}"
321 }
322
323 Button {
324 variant: ButtonVariant::Primary,
325 onclick: move |_| props.on_primary.call(()),
326 "{props.primary_label}"
327 }
328 }
329 }
330 }
331}
332
333#[derive(Props, Clone, PartialEq)]
338pub struct ProfileCardProps {
339 #[props(default)]
341 pub avatar_url: Option<String>,
342 pub name: String,
344 #[props(default)]
346 pub role: Option<String>,
347 #[props(default)]
349 pub description: Option<String>,
350 #[props(default = "Connect".to_string())]
352 pub action_label: String,
353 #[props(default)]
355 pub on_action: Option<EventHandler<()>>,
356 #[props(default)]
358 pub stats: Vec<(String, String)>,
359}
360
361#[component]
362pub fn ProfileCard(props: ProfileCardProps) -> Element {
363 let initials: String = props
364 .name
365 .split_whitespace()
366 .filter_map(|s| s.chars().next())
367 .collect::<String>()
368 .to_uppercase()
369 .chars()
370 .take(2)
371 .collect();
372
373 rsx! {
374 Card {
375 variant: CardVariant::Default,
376 full_width: true,
377
378 CardContent {
379 div {
380 style: "display: flex; flex-direction: column; align-items: center; gap: 16px; text-align: center;",
381
382 if let Some(url) = props.avatar_url.clone() {
384 img {
385 src: "{url}",
386 alt: "{props.name}",
387 style: "width: 80px; height: 80px; border-radius: 50%; object-fit: cover;",
388 }
389 } else {
390 div {
391 style: "width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 24px;",
392 "{initials}"
393 }
394 }
395
396 div {
398 Heading {
399 level: HeadingLevel::H4,
400 "{props.name}"
401 }
402
403 if let Some(role) = props.role.clone() {
404 Label {
405 size: TextSize::Small,
406 color: crate::atoms::TextColor::Muted,
407 "{role}"
408 }
409 }
410 }
411
412 if let Some(desc) = props.description.clone() {
413 p {
414 style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
415 "{desc}"
416 }
417 }
418
419 if !props.stats.is_empty() {
421 div {
422 style: "display: flex; gap: 24px; justify-content: center; width: 100%; padding-top: 8px; border-top: 1px solid #e2e8f0;",
423
424 for (label, value) in props.stats.clone() {
425 div {
426 style: "text-align: center;",
427
428 div {
429 style: "font-size: 18px; font-weight: 700; color: #0f172a;",
430 "{value}"
431 }
432
433 Label {
434 size: TextSize::ExtraSmall,
435 color: crate::atoms::TextColor::Muted,
436 "{label}"
437 }
438 }
439 }
440 }
441 }
442
443 if let Some(handler) = props.on_action.clone() {
445 Button {
446 variant: ButtonVariant::Primary,
447 full_width: true,
448 onclick: move |_| handler.call(()),
449 "{props.action_label}"
450 }
451 }
452 }
453 }
454 }
455 }
456}
457
458#[derive(Props, Clone, PartialEq)]
463pub struct PricingCardProps {
464 pub plan: String,
466 pub price: String,
468 #[props(default = "/month".to_string())]
470 pub period: String,
471 #[props(default)]
473 pub description: Option<String>,
474 pub features: Vec<String>,
476 #[props(default = "Get Started".to_string())]
478 pub cta_label: String,
479 pub on_cta: EventHandler<()>,
481 #[props(default)]
483 pub recommended: bool,
484}
485
486#[component]
487pub fn PricingCard(props: PricingCardProps) -> Element {
488 let action_element: Option<Element> = if props.recommended {
489 Some(rsx! {
490 Badge {
491 variant: BadgeVariant::Success,
492 "Recommended"
493 }
494 })
495 } else {
496 None
497 };
498
499 rsx! {
500 Card {
501 variant: if props.recommended { CardVariant::Elevated } else { CardVariant::Default },
502 full_width: true,
503
504 CardHeader {
505 title: props.plan.clone(),
506 action: action_element
507 }
508
509 CardContent {
510 div {
511 style: "display: flex; flex-direction: column; gap: 16px;",
512
513 div {
515 style: "text-align: center;",
516
517 span {
518 style: "font-size: 36px; font-weight: 800; color: #0f172a;",
519 "{props.price}"
520 }
521
522 span {
523 style: "color: #64748b; font-size: 14px;",
524 "{props.period}"
525 }
526 }
527
528 if let Some(desc) = props.description.clone() {
529 p {
530 style: "margin: 0; color: #64748b; font-size: 14px; text-align: center;",
531 "{desc}"
532 }
533 }
534
535 ul {
537 style: "margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px;",
538
539 for feature in props.features.clone() {
540 li {
541 style: "display: flex; align-items: center; gap: 8px; font-size: 14px;",
542
543 Icon {
544 name: "check".to_string(),
545 size: IconSize::Small,
546 color: IconColor::Success,
547 }
548
549 "{feature}"
550 }
551 }
552 }
553 }
554 }
555
556 CardFooter {
557 Button {
558 variant: if props.recommended { ButtonVariant::Primary } else { ButtonVariant::Secondary },
559 full_width: true,
560 onclick: move |_| props.on_cta.call(()),
561 "{props.cta_label}"
562 }
563 }
564 }
565 }
566}
567
568#[derive(Props, Clone, PartialEq)]
573pub struct HorizontalCardProps {
574 pub image_url: String,
576 pub title: String,
578 pub description: String,
580 #[props(default)]
582 pub action_label: Option<String>,
583 #[props(default)]
585 pub on_action: Option<EventHandler<()>>,
586}
587
588#[component]
589pub fn HorizontalCard(props: HorizontalCardProps) -> Element {
590 rsx! {
591 Card {
592 variant: CardVariant::Default,
593 full_width: true,
594 padding: Some("0".to_string()),
595
596 div {
597 style: "display: flex; flex-direction: row;",
598
599 div {
601 style: "width: 120px; min-height: 120px; flex-shrink: 0;",
602
603 img {
604 src: "{props.image_url}",
605 alt: "{props.title}",
606 style: "width: 100%; height: 100%; object-fit: cover; border-radius: 12px 0 0 12px;",
607 }
608 }
609
610 div {
612 style: "flex: 1; padding: 16px; display: flex; flex-direction: column; justify-content: center;",
613
614 Heading {
615 level: HeadingLevel::H4,
616 "{props.title}"
617 }
618
619 p {
620 style: "margin: 4px 0 0 0; color: #64748b; font-size: 13px; line-height: 1.4;",
621 "{props.description}"
622 }
623
624 if let Some(label) = props.action_label.clone() {
625 if let Some(handler) = props.on_action.clone() {
626 div {
627 style: "margin-top: 12px;",
628
629 Button {
630 variant: ButtonVariant::Ghost,
631 size: ButtonSize::Sm,
632 onclick: move |_| handler.call(()),
633 "{label} →"
634 }
635 }
636 }
637 }
638 }
639 }
640 }
641 }
642}
643
644#[derive(Props, Clone, PartialEq)]
649pub struct NotificationCardProps {
650 pub title: String,
652 pub message: String,
654 #[props(default)]
656 pub notification_type: NotificationType,
657 #[props(default)]
659 pub timestamp: Option<String>,
660 #[props(default)]
662 pub on_dismiss: Option<EventHandler<()>>,
663 #[props(default)]
665 pub icon: Option<String>,
666}
667
668#[derive(Clone, PartialEq, Default)]
670pub enum NotificationType {
671 #[default]
672 Info,
673 Success,
674 Warning,
675 Error,
676}
677
678#[component]
679pub fn NotificationCard(props: NotificationCardProps) -> Element {
680 let (icon, icon_color, border_color) = match props.notification_type {
681 NotificationType::Info => ("info", IconColor::Primary, "#3b82f6"),
682 NotificationType::Success => ("check-circle", IconColor::Success, "#22c55e"),
683 NotificationType::Warning => ("alert-triangle", IconColor::Warning, "#eab308"),
684 NotificationType::Error => ("x-circle", IconColor::Destructive, "#ef4444"),
685 };
686
687 let icon_name = props.icon.clone().unwrap_or_else(|| icon.to_string());
688
689 rsx! {
690 Card {
691 variant: CardVariant::Default,
692 full_width: true,
693 style: Some(format!("border-left: 4px solid {};", border_color)),
694
695 CardContent {
696 div {
697 style: "display: flex; gap: 12px;",
698
699 div {
701 style: "flex-shrink: 0; padding-top: 2px;",
702
703 Icon {
704 name: icon_name,
705 size: IconSize::Medium,
706 color: icon_color,
707 }
708 }
709
710 div {
712 style: "flex: 1; min-width: 0;",
713
714 div {
715 style: "display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;",
716
717 Heading {
718 level: HeadingLevel::H4,
719 "{props.title}"
720 }
721
722 if let Some(handler) = props.on_dismiss.clone() {
723 button {
724 style: "background: none; border: none; cursor: pointer; padding: 4px; color: #94a3b8;",
725 onclick: move |_| handler.call(()),
726 "✕"
727 }
728 }
729 }
730
731 p {
732 style: "margin: 4px 0 0 0; color: #64748b; font-size: 14px; line-height: 1.5;",
733 "{props.message}"
734 }
735
736 if let Some(time) = props.timestamp.clone() {
737 Label {
738 size: TextSize::ExtraSmall,
739 color: crate::atoms::TextColor::Muted,
740 "{time}"
741 }
742 }
743 }
744 }
745 }
746 }
747 }
748}
749
750#[derive(Props, Clone, PartialEq)]
755pub struct StatCardProps {
756 pub label: String,
758 pub value: String,
760 #[props(default)]
762 pub change: Option<String>,
763 #[props(default)]
765 pub change_positive: Option<bool>,
766 #[props(default)]
768 pub icon: Option<String>,
769 #[props(default = "#f1f5f9".to_string())]
771 pub icon_bg: String,
772}
773
774#[component]
775pub fn StatCard(props: StatCardProps) -> Element {
776 let is_positive = props.change_positive.unwrap_or(true);
777 let change_color = if is_positive { "#22c55e" } else { "#ef4444" };
778
779 rsx! {
780 Card {
781 variant: CardVariant::Default,
782 full_width: true,
783
784 CardContent {
785 div {
786 style: "display: flex; align-items: flex-start; justify-content: space-between;",
787
788 div {
790 Label {
791 size: TextSize::Small,
792 color: crate::atoms::TextColor::Muted,
793 "{props.label}"
794 }
795
796 div {
797 style: "display: flex; align-items: baseline; gap: 8px; margin-top: 4px;",
798
799 span {
800 style: "font-size: 28px; font-weight: 700; color: #0f172a;",
801 "{props.value}"
802 }
803
804 if let Some(change) = props.change.clone() {
805 span {
806 style: "font-size: 13px; font-weight: 600; color: {change_color};",
807 "{change}"
808 }
809 }
810 }
811 }
812
813 if let Some(icon) = props.icon.clone() {
815 div {
816 style: "width: 40px; height: 40px; background: {props.icon_bg}; border-radius: 10px; display: flex; align-items: center; justify-content: center;",
817
818 Icon {
819 name: icon,
820 size: IconSize::Medium,
821 color: IconColor::Primary,
822 }
823 }
824 }
825 }
826 }
827 }
828 }
829}
830
831#[derive(Props, Clone, PartialEq)]
836pub struct ExpandableCardProps {
837 pub title: String,
839 pub preview: Element,
841 pub expanded_content: Element,
843 #[props(default)]
845 pub default_expanded: bool,
846}
847
848#[component]
849pub fn ExpandableCard(props: ExpandableCardProps) -> Element {
850 let mut is_expanded = use_signal(|| props.default_expanded);
851
852 rsx! {
853 Card {
854 variant: CardVariant::Default,
855 full_width: true,
856
857 CardHeader {
858 title: props.title.clone(),
859
860 action: rsx! {
861 button {
862 style: "background: none; border: none; cursor: pointer; padding: 4px; transition: transform 200ms;",
863 style: if is_expanded() { "transform: rotate(180deg);" } else { "" },
864 onclick: move |_| is_expanded.toggle(),
865
866 Icon {
867 name: "chevron-down".to_string(),
868 size: IconSize::Medium,
869 color: IconColor::Muted,
870 }
871 }
872 }
873 }
874
875 CardContent {
876 {props.preview}
877
878 if is_expanded() {
879 div {
880 style: "margin-top: 16px; padding-top: 16px; border-top: 1px solid #e2e8f0; animation: fadeIn 200ms ease;",
881 {props.expanded_content}
882 }
883 }
884 }
885 }
886 }
887}
888
889#[derive(Props, Clone, PartialEq)]
894pub struct MediaCardProps {
895 pub media_url: String,
897 #[props(default)]
899 pub media_type: MediaType,
900 pub title: String,
902 #[props(default)]
904 pub creator: Option<String>,
905 #[props(default)]
907 pub duration: Option<String>,
908 #[props(default)]
910 pub on_like: Option<EventHandler<()>>,
911 #[props(default)]
913 pub on_share: Option<EventHandler<()>>,
914 #[props(default)]
916 pub on_play: Option<EventHandler<()>>,
917}
918
919#[derive(Clone, PartialEq, Default)]
921pub enum MediaType {
922 #[default]
923 Image,
924 Video,
925 Audio,
926}
927
928#[component]
929pub fn MediaCard(props: MediaCardProps) -> Element {
930 let overlay_icon = match props.media_type {
931 MediaType::Image => None,
932 MediaType::Video => Some("play-circle"),
933 MediaType::Audio => Some("play-circle"),
934 };
935
936 rsx! {
937 Card {
938 variant: CardVariant::Default,
939 full_width: true,
940 padding: Some("0".to_string()),
941
942 div {
944 style: "position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden;",
945
946 img {
947 src: "{props.media_url}",
948 alt: "{props.title}",
949 style: "width: 100%; height: 100%; object-fit: cover;",
950 }
951
952 if let Some(icon) = overlay_icon {
954 if let Some(handler) = props.on_play.clone() {
955 div {
956 style: "position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.3); cursor: pointer;",
957 onclick: move |_| handler.call(()),
958
959 div {
960 style: "width: 60px; height: 60px; background: white; border-radius: 50%; display: flex; align-items: center; justify-content: center;",
961
962 Icon {
963 name: icon.to_string(),
964 size: IconSize::Large,
965 color: IconColor::Primary,
966 }
967 }
968 }
969 }
970 }
971
972 if let Some(duration) = props.duration.clone() {
974 div {
975 style: "position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;",
976 "{duration}"
977 }
978 }
979 }
980
981 CardContent {
983 div {
984 style: "display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;",
985
986 div {
987 style: "flex: 1; min-width: 0;",
988
989 Heading {
990 level: HeadingLevel::H4,
991 "{props.title}"
992 }
993
994 if let Some(creator) = props.creator.clone() {
995 Label {
996 size: TextSize::Small,
997 color: crate::atoms::TextColor::Muted,
998 "{creator}"
999 }
1000 }
1001 }
1002
1003 div {
1005 style: "display: flex; gap: 8px;",
1006
1007 if let Some(handler) = props.on_like.clone() {
1008 button {
1009 style: "background: none; border: none; cursor: pointer; padding: 4px;",
1010 onclick: move |_| handler.call(()),
1011
1012 Icon {
1013 name: "heart".to_string(),
1014 size: IconSize::Medium,
1015 color: IconColor::Muted,
1016 }
1017 }
1018 }
1019
1020 if let Some(handler) = props.on_share.clone() {
1021 button {
1022 style: "background: none; border: none; cursor: pointer; padding: 4px;",
1023 onclick: move |_| handler.call(()),
1024
1025 Icon {
1026 name: "share".to_string(),
1027 size: IconSize::Medium,
1028 color: IconColor::Muted,
1029 }
1030 }
1031 }
1032 }
1033 }
1034 }
1035 }
1036 }
1037}