diff --git a/demo/index.html b/demo/index.html index c270555..a3ff16c 100644 --- a/demo/index.html +++ b/demo/index.html @@ -64,6 +64,11 @@ @@include('./partials/overlays.html') @@include('./partials/advanced-select.html') @@include('./partials/navigation-shell.html') + @@include('./partials/bottom-bar.html') + @@include('./partials/top-navbar.html') + @@include('./partials/profile-item.html') + @@include('./partials/verification-card.html') + @@include('./partials/data-row.html') @@include('./partials/backgrounds.html') @@include('./partials/breadcrumbs.html') @@include('./partials/divider.html') @@ -75,6 +80,10 @@ @@include('./partials/timeline.html') @@include('./partials/loader.html') @@include('./partials/option-card-chat.html') + @@include('./partials/campaign-cards.html') + @@include('./partials/client-folder-cards.html') + @@include('./partials/chat-inputs.html') + @@include('./partials/step-cards.html') diff --git a/demo/partials/bottom-bar.html b/demo/partials/bottom-bar.html new file mode 100644 index 0000000..5af006b --- /dev/null +++ b/demo/partials/bottom-bar.html @@ -0,0 +1,92 @@ +
+

Bottom Bar

+

Floating pill navigation from the Bottom Bar Container frame.

+ +
+ + + + + +
+ +

Vue bottom bar

+
+ +
diff --git a/demo/partials/campaign-cards.html b/demo/partials/campaign-cards.html new file mode 100644 index 0000000..6d0a9f6 --- /dev/null +++ b/demo/partials/campaign-cards.html @@ -0,0 +1,77 @@ +
+

Campaign Cards

+

Campaign summary cards from the Card - Campaign and Card - Campaign Type Figma frames.

+ +
+
+
+ +

July Newsletter

+
+
+
+ Campaign type + Email +
+
+ Audience + All Subscribers +
+
+ Upload date + July 13, 2025 +
+
+ Upload time + 08:30 am +
+
+
+ +
+
+ +

Email campaign

+
+

Send beautifully designed emails to your subscribers and track their performance.

+ +
+
+ +

Vue campaign cards

+
+ +
diff --git a/demo/partials/chat-inputs.html b/demo/partials/chat-inputs.html new file mode 100644 index 0000000..8b0d610 --- /dev/null +++ b/demo/partials/chat-inputs.html @@ -0,0 +1,53 @@ +
+

Chat Inputs

+

Rich input containers from the Input Container Chat and Input Container Messenger Figma frames.

+ +
+
+ + +
+ + +
+
+ +
+ + +
+ +
+
+
+ +

Vue chat inputs

+
+ +
diff --git a/demo/partials/client-folder-cards.html b/demo/partials/client-folder-cards.html new file mode 100644 index 0000000..c9c229e --- /dev/null +++ b/demo/partials/client-folder-cards.html @@ -0,0 +1,94 @@ +
+

Client & Folder Cards

+

Client and folder cards from the Client Card, Card - Folder and Card - Folder Option Figma frames.

+ +
+
+
+
+ +
+

Henry Stephen

+ Regular customer +
+
+ +
+
+
+ Total orders + 12 orders +
+
+ Last order + 2 days ago +
+
+
+ +
+ +
+ +

Summer sale 2025

+
+
+
+ Total asset + 12 assets +
+
+ Last updated + 2 days ago +
+
+
+ +
+ +
+ +

Summer sale 2025

+
+
+
+ +

Vue client & folder cards

+
+ +
diff --git a/demo/partials/data-row.html b/demo/partials/data-row.html new file mode 100644 index 0000000..96ee1d3 --- /dev/null +++ b/demo/partials/data-row.html @@ -0,0 +1,85 @@ +
+

Data Row

+

Compact rows for lists and search results (Asset, Order, Audience, Campaign, Contact cards).

+ +
+
+
+
+
+ Summer promo sale + + + Email + + All subscribers + +
+
+
+ 19.1% + 1.2K +
+
+ +
+
+
+
+ Henry Stephen + henry@gmail.com +
+
+
+ Active +
+
+
+ +

Vue data rows

+
+ +
diff --git a/demo/partials/element-card.html b/demo/partials/element-card.html new file mode 100644 index 0000000..7454147 --- /dev/null +++ b/demo/partials/element-card.html @@ -0,0 +1,69 @@ +
+

Element Card

+

Compact icon-label tiles from the Element Card Figma frame.

+ +
+ + + + + + +
+ +

Vue element cards

+
+ +
diff --git a/demo/partials/option-card-chat.html b/demo/partials/option-card-chat.html index 8873898..a540dd8 100644 --- a/demo/partials/option-card-chat.html +++ b/demo/partials/option-card-chat.html @@ -51,10 +51,10 @@
diff --git a/demo/partials/profile-item.html b/demo/partials/profile-item.html new file mode 100644 index 0000000..a1c7951 --- /dev/null +++ b/demo/partials/profile-item.html @@ -0,0 +1,65 @@ +
+

Profile Item

+

Rounded action rows from the Profile Figma frame.

+ +
+ + + + + +
+ +

Vue profile items

+
+ +
diff --git a/demo/partials/step-cards.html b/demo/partials/step-cards.html new file mode 100644 index 0000000..70a4e9f --- /dev/null +++ b/demo/partials/step-cards.html @@ -0,0 +1,79 @@ +
+

Step & Scheduled Campaign Cards

+

Step progress cards from Card - Step and scheduled campaign cards from Card - Scheduled Campaign Figma frames.

+ +
+
+ Step 1 +

Define Your Audience Goals

+

Tell us your focus — we’ll shape your contact list and segments.

+ +
+ +
+ Step 1 +

Define Your Audience Goals

+

Tell us your focus — we’ll shape your contact list and segments.

+ +
+ +
+

Summer Sale 2025

+

Email • All subscribers

+
+ +
+

Summer Sale 2025

+

Email • All subscribers

+
+
+ +

Vue step & scheduled campaign cards

+
+ +
diff --git a/demo/partials/top-navbar.html b/demo/partials/top-navbar.html new file mode 100644 index 0000000..bfaa351 --- /dev/null +++ b/demo/partials/top-navbar.html @@ -0,0 +1,141 @@ +
+

Top Navbar

+

Application header bars from the Top Navbar frame.

+ +
+
+ + + Oselya.AI + +
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+
+

Title

+
+
+ + +
+
+ +
+ + + Oselya.AI + +
+

Title

+
+
+ + +
+
+
+ +
+
+ +
+

Name

+ Статус • Статус контактів +
+
+ +
+
+
+ +

Vue top navbar

+
+ +
diff --git a/demo/partials/verification-card.html b/demo/partials/verification-card.html new file mode 100644 index 0000000..b266a52 --- /dev/null +++ b/demo/partials/verification-card.html @@ -0,0 +1,103 @@ +
+

Verification Card

+

Verification status panels from the Characteristics Container Figma frame.

+ +
+
+
+

Верифіковано

+
+
    +
  • + + Відеоогляд записано через додаток Oselya.AI +
  • +
  • + + Геолокація та адреса збігаються +
  • +
  • + + Власник / орендодавець на зв'язку +
  • +
+
+ Оголошення пройшло цифрову верифікацію. Автор записав відеоогляд помешкання безпосередньо через наш додаток. +
+
+ +
+
+ Остання перевірка 09.07.2026, 10:15 +
+
+ +
+
+

Оголошення не перевірено системою

+
+
+ Це помешкання ще не пройшло відеоверифікацію в нашому додатку. Ми не можемо гарантувати 100% відповідність фотографій та опису реальному стану об'єкта. +
+
+

Допоможіть нам стати ще точнішими

+

Знайшли розбіжність між описом та реальністю? Поділіться цим із нашою службою контролю якості.

+ +
+
+
+ +

Vue verification cards

+
+ +
diff --git a/docs/component-coverage.md b/docs/component-coverage.md index 0343b7f..7fc864e 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -37,6 +37,12 @@ | Tooltip | done | GnTooltip | partial | | Dropdown | done | GnDropdown, GnDropdownItem | partial | | NavigationShell | done | GnNavigationShell, GnNavigationItem | partial | +| BottomBar | done | GnBottomBar | partial | +| TopNavbar | done | GnTopNavbar | partial | +| ProfileItem | done | GnProfileItem | partial | +| VerificationCard | done | GnVerificationCard | partial | +| DataRow | done | GnDataRow | partial | +| ElementCard | done | GnElementCard | partial | | DescriptionList | done | GnDescriptionList | partial | | DefinitionList | done | GnDefinitionList | partial | | List | done | GnList, GnListItem | partial | diff --git a/public/assets/Folder.png b/public/assets/Folder.png new file mode 100644 index 0000000..3bc63d9 --- /dev/null +++ b/public/assets/Folder.png Binary files differ diff --git a/src/scss/components/_bottom-bar.scss b/src/scss/components/_bottom-bar.scss new file mode 100644 index 0000000..f748ed0 --- /dev/null +++ b/src/scss/components/_bottom-bar.scss @@ -0,0 +1,108 @@ +/* ========================= + Bottom Bar + Figma: Bottom Bar Container. + Floating pill nav for primary app sections. +========================= */ + +@use "../kit-deps" as *; + +.bottom-bar { + display: inline-flex; + align-items: center; + list-style: none; + margin: 0; + padding: $space-1; + border-radius: $radius-full; + background-color: $neutral-0; + box-shadow: + 0 4px 24px rgba($neutral-900, 0.08), + 0 1px 2px rgba($neutral-900, 0.04), + inset 0 0 0 1px rgba($neutral-900, 0.04); + + &--stacked { + gap: $space-1; + } + + &--inline { + gap: $space-1; + background-color: transparent; + box-shadow: none; + padding: 0; + } + + &__item { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $space-1; + padding: $space-2 $space-4; + border: 0; + border-radius: $radius-full; + background-color: transparent; + color: $neutral-500; + font-family: inherit; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + line-height: $line-height-tight; + text-decoration: none; + cursor: pointer; + transition: + color $motion-base $motion-ease, + background-color $motion-base $motion-ease, + box-shadow $motion-base $motion-ease; + + &:focus-visible { + @include focus_ring; + outline-offset: 2px; + } + + &:hover:not(.is-active) { + color: $neutral-700; + } + + .bottom-bar--stacked & { + flex-direction: column; + min-width: 64px; + padding: $space-2 $space-3; + } + + .bottom-bar--inline &.is-active, + .bottom-bar--inline &:active { + background-color: $neutral-0; + color: $color-secondary; + box-shadow: + 0 4px 24px rgba($neutral-900, 0.08), + 0 1px 2px rgba($neutral-900, 0.04), + inset 0 0 0 1px rgba($neutral-900, 0.04); + } + + &.is-active { + color: $color-secondary; + + .bottom-bar--stacked & { + background-color: transparent; + box-shadow: none; + } + } + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: $icon-size-md; + height: $icon-size-md; + flex-shrink: 0; + color: currentColor; + + svg { + width: 100%; + height: 100%; + fill: currentColor; + } + } + + &__label { + white-space: nowrap; + } +} diff --git a/src/scss/components/_campaign-card.scss b/src/scss/components/_campaign-card.scss new file mode 100644 index 0000000..baa8498 --- /dev/null +++ b/src/scss/components/_campaign-card.scss @@ -0,0 +1,132 @@ +/* ========================= + Campaign Card + Figma: Card - Campaign / Card - Campaign Type. + Summary card with icon header, title and a 2x2 metadata grid. +========================= */ + +@use "../kit-deps" as *; + +.campaign-card { + display: flex; + flex-direction: column; + width: 100%; + max-width: 560px; + padding: $space-6; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + + &__header { + display: flex; + align-items: center; + gap: $space-4; + margin-bottom: $space-6; + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + flex-shrink: 0; + border-radius: $radius-full; + background-color: $lavender-50; + color: $lavender-700; + + .vmk-icon, + .ph { + width: 28px; + height: 28px; + font-size: 28px; + } + } + + &__title { + display: flex; + align-items: center; + min-height: 56px; + margin: -0.2em 0; + padding: 0; + font-size: $font-size-xl; + font-weight: $font-weight-bold; + line-height: 1; + color: $neutral-900; + } + + &__meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-5 $space-4; + } + + &__meta-item { + display: flex; + flex-direction: column; + gap: $space-2; + } + + &__meta-label { + color: $neutral-500; + font-size: $font-size-md; + font-weight: $font-weight-regular; + line-height: $line-height-tight; + } + + &__meta-value { + color: $neutral-900; + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + line-height: $line-height-tight; + } + + &__action { + display: flex; + align-items: center; + justify-content: center; + margin-top: $space-6; + padding: $space-4 $space-5; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-full; + background-color: $neutral-0; + color: $neutral-900; + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + text-decoration: none; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-50; + border-color: $neutral-300; + } + } + + &--type { + .campaign-card__icon { + border-radius: $radius-lg; + border: $border-width-base $border-style-base $lavender-100; + background-color: $lavender-50; + color: $lavender-700; + } + + .campaign-card__meta { + grid-template-columns: 1fr; + gap: $space-3; + } + + .campaign-card__text { + margin: 0 0 $space-4; + color: $neutral-500; + font-size: $font-size-md; + line-height: $line-height-normal; + } + + .campaign-card__action { + border-radius: $radius-xl; + } + } +} diff --git a/src/scss/components/_chat-input.scss b/src/scss/components/_chat-input.scss new file mode 100644 index 0000000..bb8f787 --- /dev/null +++ b/src/scss/components/_chat-input.scss @@ -0,0 +1,169 @@ +/* ========================= + Chat Input Container + Figma: Input Container Chat / Input Container Messenger. + Rich input bar with attachment, text field, send and optional actions. +========================= */ + +@use "../kit-deps" as *; + +.chat-input { + display: flex; + align-items: flex-end; + gap: $space-2; + width: 100%; + padding: $space-2; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + + &__prefix, + &__action { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + border: 0; + border-radius: $radius-full; + background-color: transparent; + color: $neutral-500; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-100; + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + } + + .vmk-icon, + .ph { + width: 20px; + height: 20px; + font-size: 20px; + } + } + + &__prefix { + align-self: flex-end; + width: 36px; + height: 36px; + margin-bottom: 2px; + } + + &__field { + flex: 1 1 auto; + min-height: 40px; + max-height: 120px; + padding: $space-2 $space-1; + border: 0; + background-color: transparent; + color: $neutral-900; + font-family: inherit; + font-size: $font-size-md; + line-height: $line-height-normal; + resize: none; + outline: none; + + &::placeholder { + color: $neutral-500; + } + } + + &__actions { + display: inline-flex; + align-items: center; + gap: $space-2; + flex-shrink: 0; + margin-bottom: 2px; + } + + &__action { + width: 40px; + height: 40px; + + &--mic { + background-color: transparent; + border: 1px solid $mint-300; + color: $mint-700; + + &:hover { + background-color: $mint-50; + color: $mint-800; + } + } + + &--send { + background-color: $yellow-300; + color: $neutral-900; + + &:hover { + background-color: $yellow-400; + color: $neutral-900; + } + } + } + + &--messenger { + align-items: center; + border-radius: $radius-full; + padding: $space-1 $space-2; + + .chat-input__prefix { + align-self: center; + width: 32px; + height: 32px; + margin: 0; + color: $mint-700; + + &:hover { + background-color: $mint-50; + color: $mint-800; + } + + .vmk-icon, + .ph { + width: 18px; + height: 18px; + font-size: 18px; + } + } + + .chat-input__field { + min-height: 36px; + padding: $space-1 0; + } + + .chat-input__actions { + margin: 0; + } + + .chat-input__action { + width: 36px; + height: 36px; + + .vmk-icon, + .ph { + width: 18px; + height: 18px; + font-size: 18px; + } + + &--send { + background-color: transparent; + border: 1px solid $mint-500; + color: $mint-700; + + &:hover { + background-color: $mint-50; + color: $mint-800; + } + } + } + } +} diff --git a/src/scss/components/_chat.scss b/src/scss/components/_chat.scss index ba86d82..c3cc3bc 100644 --- a/src/scss/components/_chat.scss +++ b/src/scss/components/_chat.scss @@ -5,6 +5,14 @@ @use "../kit-deps" as *; +.chat-list { + display: flex; + flex-direction: column; + gap: $space-4; + width: 100%; + max-width: 560px; +} + .chat-message { display: flex; flex-direction: column; @@ -21,6 +29,10 @@ color: $neutral-900; border-bottom-right-radius: $radius-xs; } + + .chat-message-meta { + text-align: right; + } } &.chat-message-ai { @@ -33,6 +45,10 @@ color: $neutral-900; border-bottom-left-radius: $radius-xs; } + + .chat-message-meta { + text-align: left; + } } } @@ -70,6 +86,15 @@ font-weight: $font-weight-semibold; } +.chat-message-meta { + display: inline-flex; + align-items: center; + gap: $space-2; + color: $neutral-500; + font-size: $font-size-xs; + line-height: 1; +} + .chat-message-actions { display: inline-flex; align-items: center; @@ -139,6 +164,13 @@ } } +.chat-message-group { + display: flex; + flex-direction: column; + gap: $space-2; + width: 100%; +} + .chat-input-container { display: flex; align-items: center; @@ -199,18 +231,6 @@ } @keyframes chat-typing-bounce { - 0%, 80%, 100% { transform: scale(0.6); opacity: 0.6; } - 40% { transform: scale(1); opacity: 1; } -} - -.chat-list { - display: flex; - flex-direction: column; - gap: $space-4; - width: 100%; - max-width: 720px; - padding: $space-3; - border: 1px solid $neutral-100; - border-radius: $radius-lg; - background-color: $neutral-50; + 0%, 80%, 100% { transform: scale(0); } + 40% { transform: scale(1); } } diff --git a/src/scss/components/_chips.scss b/src/scss/components/_chips.scss index 748e810..a28573d 100644 --- a/src/scss/components/_chips.scss +++ b/src/scss/components/_chips.scss @@ -102,6 +102,26 @@ } } + &.chip-suggestion { + text-transform: none; + background-color: $neutral-0; + border-color: $neutral-300; + color: $lavender-700; + + &:hover { + border-color: $lavender-400; + color: $lavender-800; + } + + &.chip-selected, + &[aria-pressed="true"], + &[aria-selected="true"] { + background-color: $lavender-100; + border-color: $lavender-300; + color: $lavender-800; + } + } + // Selectable / pressed state &.chip-selected, &[aria-pressed="true"], diff --git a/src/scss/components/_client-card.scss b/src/scss/components/_client-card.scss new file mode 100644 index 0000000..b3c5786 --- /dev/null +++ b/src/scss/components/_client-card.scss @@ -0,0 +1,135 @@ +/* ========================= + Client Card + Figma: Client Card. + Horizontal card with avatar, name, summary and a trailing menu. +========================= */ + +@use "../kit-deps" as *; + +.client-card { + display: flex; + flex-direction: column; + width: 100%; + max-width: 420px; + padding: $space-5; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-4; + margin-bottom: $space-5; + } + + &__main { + display: flex; + align-items: center; + gap: $space-4; + min-width: 0; + } + + &__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + flex-shrink: 0; + border-radius: $radius-full; + background-color: $neutral-100; + color: $neutral-700; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .vmk-icon, + .ph { + width: 28px; + height: 28px; + } + } + + &__info { + display: flex; + flex-direction: column; + gap: $space-1; + min-width: 0; + } + + &__name { + margin: -0.2em 0; + padding: 0; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + line-height: 1; + color: $neutral-900; + } + + &__role { + color: $neutral-500; + font-size: $font-size-md; + line-height: $line-height-tight; + } + + &__menu { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + padding: 0; + border: 0; + border-radius: $radius-full; + background-color: transparent; + color: $neutral-500; + font-size: 20px; + cursor: pointer; + transition: background-color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-100; + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + } + } + + &__details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-4; + padding: $space-4; + border: $border-width-base $border-style-base $neutral-100; + border-radius: $radius-lg; + background-color: $neutral-50; + } + + &__detail { + display: flex; + flex-direction: column; + gap: $space-1; + + &-label { + color: $neutral-500; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + } + + &-value { + color: $neutral-900; + font-size: $font-size-md; + font-weight: $font-weight-semibold; + } + } +} diff --git a/src/scss/components/_data-row.scss b/src/scss/components/_data-row.scss index c55aae4..2724176 100644 --- a/src/scss/components/_data-row.scss +++ b/src/scss/components/_data-row.scss @@ -1,7 +1,7 @@ /* ========================= Data Row - Figma: Row - Asset List / Order list / Audience contact list. - A compact table-like row with leading checkbox, media, columns and actions. + Figma: Row - Asset List / Order list / Audience contact list, + Campain search card / Contact search card. ========================= */ @use "../kit-deps" as *; @@ -9,126 +9,168 @@ .data-row { display: flex; align-items: center; - gap: $space-3; + gap: $space-4; width: 100%; padding: $space-3 $space-4; - border-bottom: 1px solid $neutral-100; + border: $border-width-base $border-style-base $border-color-muted; + border-radius: $radius-xl; background-color: $neutral-0; - transition: background-color $motion-base $motion-ease; + color: $neutral-900; - &:last-child { - border-bottom: 0; - } + &-select { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; - @include hover_touch { - background-color: $neutral-50; - } - - &.data-row-selected { - background-color: rgba($mint-500, 0.08); - } -} - -.data-row-select { - display: inline-flex; - align-items: center; - flex-shrink: 0; -} - -.data-row-media { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 44px; - height: 44px; - border-radius: $radius-md; - background-color: $neutral-100; - color: $neutral-500; - font-size: $icon-size-md; - overflow: hidden; - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } -} - -.data-row-content { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: $space-3 $space-4; - flex: 1 1 auto; - min-width: 0; -} - -.data-row-cell { - display: flex; - flex-direction: column; - gap: $space-1; - min-width: 0; - - &.data-row-cell-main { - .data-row-value { - font-weight: $font-weight-semibold; - color: $neutral-900; + input { + width: 18px; + height: 18px; + margin: 0; + cursor: pointer; } } -} -.data-row-label { - margin-bottom: $space-1; - color: $neutral-500; - font-size: $font-size-xs; - font-weight: $font-weight-semibold; - text-transform: uppercase; - letter-spacing: 0.03em; -} + &-media { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + flex-shrink: 0; + border-radius: $radius-full; + border: $border-width-base $border-style-base $neutral-200; + background-color: $neutral-0; + color: $lavender-700; + overflow: hidden; -.data-row-value { - color: $neutral-700; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - line-height: 1.4; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} + img { + width: 100%; + height: 100%; + object-fit: cover; + } -.data-row-actions { - display: inline-flex; - align-items: center; - gap: $space-1; - flex-shrink: 0; - margin-left: auto; -} + svg { + width: 24px; + height: 24px; + } -.data-row-group { - display: flex; - flex-direction: column; - width: 100%; - border: 1px solid $neutral-200; - border-radius: $radius-lg; - overflow: hidden; - background-color: $neutral-0; -} - -@media (max-width: 640px) { - .data-row { - align-items: flex-start; - flex-wrap: wrap; + &--icon { + border-radius: $radius-full; + } } - .data-row-content { - grid-template-columns: 1fr 1fr; - width: 100%; + &-content { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: $space-6; + min-width: 0; + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + gap: $space-2; + } } - .data-row-actions { - width: 100%; - justify-content: flex-end; - margin-left: 0; + &-cell { + display: flex; + flex-direction: column; + gap: $space-1; + min-width: 0; + + &-main { + flex: 1 1 auto; + } + } + + &-label { + color: $neutral-500; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &-value { + color: $neutral-900; + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + line-height: $line-height-tight; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &-meta { + display: inline-flex; + align-items: center; + gap: $space-2; + color: $neutral-500; + font-size: $font-size-md; + font-weight: $font-weight-regular; + line-height: $line-height-tight; + + .vmk-icon, + .ph { + width: 18px; + height: 18px; + color: currentColor; + } + + &-separator { + width: 4px; + height: 4px; + border-radius: 50%; + background-color: currentColor; + opacity: 0.6; + } + } + + &-metrics { + display: inline-flex; + align-items: center; + gap: $space-2; + margin-left: auto; + flex-shrink: 0; + } + + &-metric { + display: inline-flex; + align-items: center; + gap: $space-2; + padding: $space-2 $space-4; + border-radius: $radius-full; + border: $border-width-base $border-style-base $neutral-200; + background-color: $neutral-0; + color: $lavender-700; + font-size: $font-size-md; + font-weight: $font-weight-semibold; + line-height: 1; + white-space: nowrap; + + .vmk-icon, + .ph { + width: 20px; + height: 20px; + color: currentColor; + } + + &--success { + color: $success-text; + } + } + + &-actions { + display: inline-flex; + align-items: center; + gap: $space-2; + margin-left: auto; + flex-shrink: 0; + } + + &:hover { + border-color: $neutral-300; } } diff --git a/src/scss/components/_element-card.scss b/src/scss/components/_element-card.scss new file mode 100644 index 0000000..815ed5d --- /dev/null +++ b/src/scss/components/_element-card.scss @@ -0,0 +1,106 @@ +/* ========================= + Element Card + Figma: Element Card. + Compact vertical icon-label tile, optionally with a Pro badge. +========================= */ + +@use "../kit-deps" as *; + +.element-card { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $space-4; + min-width: 104px; + min-height: 112px; + padding: $space-5 $space-4; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease, + box-shadow $motion-base $motion-ease; + + &:hover:not(.is-selected):not(:disabled) { + border-color: rgba($mint-500, 0.5); + background-color: $mint-50; + + .element-card__icon { + border-color: rgba($mint-500, 0.25); + background-color: $mint-100; + color: $mint-700; + } + } + + &:focus-visible { + @include focus_ring; + outline-offset: 2px; + } + + &:disabled, + &.is-disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.is-selected { + border-color: $mint-500; + background-color: $mint-100; + + .element-card__icon { + background: linear-gradient(135deg, rgba($mint-500, 0.12), rgba($yellow-400, 0.12)); + border-color: rgba($mint-500, 0.35); + color: $mint-700; + } + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 14px; + border: $border-width-base $border-style-base $neutral-100; + background-color: $mint-50; + color: $mint-700; + + .vmk-icon, + .ph { + width: 28px; + height: 28px; + } + } + + &__label { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + line-height: $line-height-tight; + color: currentColor; + } + + &__badge { + position: absolute; + top: $space-2; + right: $space-2; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 20px; + padding: 0 $space-2; + border-radius: $radius-full; + background-color: $yellow-300; + color: $neutral-900; + font-size: 11px; + font-weight: $font-weight-bold; + line-height: 1; + } +} diff --git a/src/scss/components/_folder-card.scss b/src/scss/components/_folder-card.scss new file mode 100644 index 0000000..ef6303b --- /dev/null +++ b/src/scss/components/_folder-card.scss @@ -0,0 +1,176 @@ +/* ========================= + Folder Card + Figma: Card - Folder / Card - Folder Option. + Vertical/horizontal folder card with icon, title, options menu and summary. +========================= */ + +@use "../kit-deps" as *; + +.folder-card { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + max-width: 280px; + padding: $space-5; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + text-align: center; + cursor: pointer; + transition: + border-color $motion-base $motion-ease, + background-color $motion-base $motion-ease; + + &:hover:not(.is-selected) { + border-color: $neutral-300; + background-color: $neutral-50; + } + + &:focus-visible { + @include focus_ring; + outline-offset: 2px; + } + + &.is-selected { + border-color: $mint-500; + background-color: $mint-50; + } + + &--horizontal { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: $space-4; + max-width: 480px; + text-align: left; + + .folder-card__main { + flex-direction: row; + align-items: center; + } + + .folder-card__icon { + width: 52px; + height: 52px; + + .vmk-icon, + .ph { + width: 48px; + height: 48px; + font-size: 48px; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + + .folder-card__summary { + display: none; + } + } + + &__menu { + position: absolute; + top: $space-4; + right: $space-4; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: $radius-full; + background-color: transparent; + color: $neutral-500; + font-size: 20px; + cursor: pointer; + transition: background-color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-100; + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + } + } + + &__main { + display: flex; + flex-direction: column; + align-items: center; + gap: $space-4; + margin-bottom: $space-5; + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + flex-shrink: 0; + color: $lavender-500; + + .vmk-icon, + .ph { + width: 80px; + height: 80px; + font-size: 80px; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + + &__title { + margin: -0.2em 0; + padding: 0; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + line-height: 1; + color: $neutral-900; + } + + &__summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-4; + padding: $space-4; + border: $border-width-base $border-style-base $neutral-100; + border-radius: $radius-lg; + background-color: $neutral-50; + } + + &__summary-item { + display: flex; + flex-direction: column; + gap: $space-1; + + &--full { + grid-column: 1 / -1; + } + } + + &__summary-label { + color: $neutral-500; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + } + + &__summary-value { + color: $neutral-900; + font-size: $font-size-md; + font-weight: $font-weight-semibold; + } +} diff --git a/src/scss/components/_option-card.scss b/src/scss/components/_option-card.scss index ddea6a8..9ac5cc1 100644 --- a/src/scss/components/_option-card.scss +++ b/src/scss/components/_option-card.scss @@ -7,6 +7,7 @@ @use "../kit-deps" as *; .option-card { + position: relative; display: flex; flex-direction: column; width: 100%; @@ -15,6 +16,7 @@ border: 1px solid $neutral-200; border-radius: $radius-xl; overflow: hidden; + cursor: pointer; transition: box-shadow $motion-base $motion-ease, border-color $motion-base $motion-ease; @@ -24,6 +26,139 @@ box-shadow: 0 4px 24px rgba($neutral-900, 0.08); } + &__indicator { + position: absolute; + top: $space-5; + right: $space-5; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: $radius-full; + border: 2px solid $neutral-300; + background-color: transparent; + color: $neutral-0; + pointer-events: none; + transition: + border-color $motion-base $motion-ease, + background-color $motion-base $motion-ease; + + &--radio::after { + content: ""; + width: 10px; + height: 10px; + border-radius: $radius-full; + background-color: currentColor; + opacity: 0; + transform: scale(0); + transition: + opacity $motion-base $motion-ease, + transform $motion-base $motion-ease; + } + + &--check::after { + content: ""; + width: 12px; + height: 7px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + opacity: 0; + transform: rotate(-45deg) scale(0.8); + transition: + opacity $motion-base $motion-ease, + transform $motion-base $motion-ease; + } + } + + &--selectable { + cursor: pointer; + + &.is-selected, + &[aria-selected="true"] { + border-color: $lavender-600; + background-color: $neutral-0; + box-shadow: 0 0 0 1px $lavender-600; + + .option-card__icon { + background: linear-gradient(135deg, rgba($mint-500, 0.12), rgba($yellow-400, 0.12)); + border-color: $lavender-600; + } + + .option-card__indicator--radio { + border-color: $mint-500; + background-color: $mint-500; + + &::after { + opacity: 1; + transform: scale(1); + } + } + + .option-card__indicator--check { + border-color: $success-text; + background-color: $success-text; + + &::after { + opacity: 1; + transform: rotate(-45deg) scale(1); + } + } + } + } + + &--lg { + max-width: 320px; + + .option-card-media { + display: none; + } + + .option-card__icon { + width: 56px; + height: 56px; + border-radius: $radius-lg; + border: 1px solid $neutral-200; + background-color: $neutral-50; + margin-bottom: $space-2; + } + + .option-card-title { + font-size: $font-size-lg; + font-weight: $font-weight-bold; + color: $neutral-900; + } + + .option-card-description { + color: $neutral-500; + font-size: $font-size-md; + } + + .option-card-body { + gap: $space-3; + padding: $space-6; + } + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: $radius-md; + border: 1px solid $neutral-100; + background-color: $neutral-50; + color: $lavender-700; + + .vmk-icon, + .ph { + width: 28px; + height: 28px; + } + } + &.option-card-horizontal { flex-direction: row; max-width: 720px; @@ -188,6 +323,13 @@ line-height: 1.4; } +.option-card-description { + margin: 0; + color: $neutral-500; + font-size: $font-size-sm; + line-height: $line-height-normal; +} + .option-card-badges { display: flex; flex-wrap: wrap; diff --git a/src/scss/components/_profile-item.scss b/src/scss/components/_profile-item.scss new file mode 100644 index 0000000..5232a37 --- /dev/null +++ b/src/scss/components/_profile-item.scss @@ -0,0 +1,122 @@ +/* ========================= + Profile item + Figma: Profile. + Horizontal rounded action row: avatar/icon, title, trailing chevron. +========================= */ + +@use "sass:color"; +@use "../kit-deps" as *; + +.profile-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-4; + width: 100%; + padding: $space-4 $space-5; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + text-decoration: none; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease, + color $motion-base $motion-ease; + + &:hover:not(.is-danger):not(:disabled), + &.is-active { + background-color: $mint-50; + border-color: rgba($mint-500, 0.5); + } + + &:focus-visible { + @include focus_ring; + outline-offset: 2px; + } + + &:disabled, + &.is-disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.is-danger { + border-color: rgba($danger-text, 0.2); + background-color: $neutral-0; + color: $danger-text; + + .profile-item__icon { + background-color: rgba($danger-text, 0.08); + color: $danger-text; + } + + &:hover:not(:disabled) { + background-color: rgba($danger-text, 0.06); + border-color: rgba($danger-text, 0.3); + } + } + + &__main { + display: flex; + align-items: center; + gap: $space-4; + min-width: 0; + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: $radius-full; + background-color: $neutral-100; + color: $neutral-700; + + svg { + width: 24px; + height: 24px; + } + + img { + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; + } + } + + &__content { + display: flex; + flex-direction: column; + gap: $space-1; + min-width: 0; + } + + &__title { + margin: 0; + font-size: $font-size-md; + font-weight: $font-weight-regular; + line-height: $line-height-tight; + color: currentColor; + } + + &__meta { + color: $neutral-500; + font-size: $font-size-sm; + line-height: $line-height-tight; + } + + &__chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + color: $color-secondary; + } +} diff --git a/src/scss/components/_scheduled-campaign-card.scss b/src/scss/components/_scheduled-campaign-card.scss new file mode 100644 index 0000000..89cba36 --- /dev/null +++ b/src/scss/components/_scheduled-campaign-card.scss @@ -0,0 +1,64 @@ +/* ========================= + Scheduled Campaign Card + Figma: Card - Scheduled Campaign. + Compact card with a coloured left accent bar, title and campaign meta. +========================= */ + +@use "../kit-deps" as *; + +.scheduled-campaign-card { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + gap: $space-1; + width: 100%; + max-width: 320px; + min-height: auto; + padding: $space-3 $space-4 $space-3 calc($space-4 + 12px); + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + text-align: left; + overflow: hidden; + + &::before { + content: ""; + position: absolute; + top: $space-3; + bottom: $space-3; + left: $space-4; + width: 4px; + border-radius: $radius-full; + background-color: $neutral-300; + } + + &--scheduled::before { + background-color: $mint-500; + } + + &--sent::before { + background-color: $success-text; + } + + &--draft::before { + background-color: $neutral-300; + } + + &__title { + margin: -0.2em 0; + padding: 0; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + line-height: 1; + color: $neutral-900; + } + + &__meta { + margin: 0; + color: $neutral-500; + font-size: $font-size-md; + line-height: $line-height-normal; + } +} diff --git a/src/scss/components/_step-card.scss b/src/scss/components/_step-card.scss new file mode 100644 index 0000000..f88050d --- /dev/null +++ b/src/scss/components/_step-card.scss @@ -0,0 +1,92 @@ +/* ========================= + Step Card + Figma: Card - Step. + Vertical card with a step badge, title, description and a bottom action. +========================= */ + +@use "../kit-deps" as *; + +.step-card { + display: flex; + flex-direction: column; + width: 100%; + max-width: 420px; + padding: $space-5; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + text-align: left; + + &__badge { + align-self: flex-start; + display: inline-flex; + align-items: center; + padding: $space-1 $space-3; + border: 1px solid $lavender-200; + border-radius: $radius-full; + background-color: $lavender-50; + color: $lavender-700; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + margin-bottom: $space-6; + } + + &__title { + margin: -0.2em 0 calc($space-3 - 0.2em); + padding: 0; + font-size: $font-size-xl; + font-weight: $font-weight-bold; + line-height: 1; + color: $neutral-900; + } + + &__description { + margin: 0 0 $space-6; + color: $neutral-500; + font-size: $font-size-md; + line-height: $line-height-normal; + } + + &__action { + display: flex; + align-items: center; + justify-content: center; + gap: $space-2; + width: 100%; + margin-top: auto; + padding: $space-3 $space-5; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + font-family: inherit; + font-size: $font-size-md; + font-weight: $font-weight-semibold; + line-height: $line-height-normal; + text-decoration: none; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-50; + border-color: $neutral-300; + } + + &:focus-visible { + @include focus_ring; + } + + &--done { + color: $success-text; + } + + .ph, + .vmk-icon { + width: 20px; + height: 20px; + } + } +} diff --git a/src/scss/components/_top-navbar.scss b/src/scss/components/_top-navbar.scss new file mode 100644 index 0000000..2b4036c --- /dev/null +++ b/src/scss/components/_top-navbar.scss @@ -0,0 +1,167 @@ +/* ========================= + Top Navbar + Figma: Top Navbar. + App-level header bars: ghost (transparent over dark bg), solid white, + centered logo, and compact mobile header. +========================= */ + +@use "../kit-deps" as *; + +.top-navbar { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-4; + width: 100%; + + &--ghost { + padding: $space-4 $space-5; + color: $neutral-0; + } + + &--solid { + padding: $space-3 $space-5; + background-color: $neutral-0; + border-bottom: $border-width-base $border-style-base $neutral-100; + color: $neutral-900; + + .top-navbar__center .top-navbar__title { + color: $color-secondary; + } + } + + &--centered { + justify-content: center; + padding: $space-5; + background-color: transparent; + color: $neutral-0; + } + + &--compact { + padding: $space-2 $space-4; + background-color: $neutral-0; + border-bottom: $border-width-base $border-style-base $neutral-100; + color: $neutral-900; + + .top-navbar__center .top-navbar__title { + color: $color-secondary; + } + } + + &__brand { + display: inline-flex; + align-items: center; + gap: $space-3; + min-width: 0; + text-decoration: none; + color: inherit; + + &:hover { + color: inherit; + } + } + + &__logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: $radius-full; + background: radial-gradient(circle, $yellow-300 40%, $mint-500 41%, $mint-500 100%); + box-shadow: inset 0 0 0 2px rgba($neutral-0, 0.6); + + &--sm { + width: 32px; + height: 32px; + } + } + + &__title { + margin: 0; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + line-height: $line-height-tight; + color: inherit; + } + + &__center { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: $space-3; + max-width: 60%; + + &--stack { + flex-direction: column; + gap: $space-1; + } + + .top-navbar--centered & { + position: static; + transform: none; + max-width: none; + } + + .top-navbar--compact & { + position: static; + transform: none; + flex: 1 1 auto; + justify-content: center; + max-width: none; + } + } + + &__actions { + display: inline-flex; + align-items: center; + gap: $space-3; + margin-left: auto; + min-width: 0; + } + + &__meta { + display: block; + color: $neutral-500; + font-size: $font-size-sm; + } + + &__pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $space-2; + padding: $space-2 $space-4; + border-radius: $radius-full; + border: $border-width-base $border-style-base $neutral-200; + background-color: $neutral-0; + color: $color-secondary; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + text-decoration: none; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease, + color $motion-base $motion-ease; + + &:focus-visible { + @include focus_ring; + outline-offset: 2px; + } + + &:hover { + background-color: $neutral-50; + border-color: $neutral-300; + } + + .vmk-icon, + .ph { + color: currentColor; + } + } +} diff --git a/src/scss/components/_verification-card.scss b/src/scss/components/_verification-card.scss new file mode 100644 index 0000000..e9a7447 --- /dev/null +++ b/src/scss/components/_verification-card.scss @@ -0,0 +1,207 @@ +/* ========================= + Verification card + Figma: Characteristics Container. + Status verification panel with badge, checklist, nested info box, CTA, timestamp. +========================= */ + +@use "sass:color"; +@use "../kit-deps" as *; + +.verification-card { + display: flex; + flex-direction: column; + gap: $space-5; + width: 100%; + max-width: 420px; + padding: $space-6; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-xl; + background-color: $neutral-0; + color: $neutral-900; + + &--verified { + background-color: $mint-50; + border-color: rgba($mint-500, 0.35); + + .verification-card__title { + color: $mint-900; + } + } + + &--warning { + background-color: $warning-bg; + border-color: rgba($warning-text, 0.25); + + .verification-card__title { + color: $warning-text; + } + } + + &--danger { + background-color: rgba($danger-bg, 0.7); + border-color: rgba($danger-text, 0.25); + + .verification-card__title { + color: $danger-text; + } + } + + &__header { + display: flex; + flex-direction: column; + gap: $space-3; + } + + &__title { + margin: 0; + font-size: $font-size-xl; + font-weight: $font-weight-bold; + line-height: $line-height-tight; + color: inherit; + } + + &__status { + display: inline-flex; + align-items: center; + gap: $space-2; + width: fit-content; + padding: $space-1 $space-3; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-full; + background-color: $neutral-0; + color: $success-text; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + + .verification-card--warning & { + color: $warning-text; + border-color: rgba($warning-text, 0.2); + } + + .verification-card--danger & { + color: $danger-text; + border-color: rgba($danger-text, 0.2); + } + } + + &__list { + display: flex; + flex-direction: column; + gap: $space-3; + margin: 0; + padding: 0; + list-style: none; + } + + &__item { + display: flex; + align-items: flex-start; + gap: $space-3; + font-size: $font-size-md; + line-height: $line-height-normal; + color: $neutral-800; + + .vmk-icon, + .ph { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 2px; + color: $success-text; + } + + .verification-card--warning & { + .vmk-icon, + .ph { + color: $warning-text; + } + } + + .verification-card--danger & { + color: $neutral-700; + + .vmk-icon, + .ph { + color: $danger-text; + } + } + } + + &__body { + display: flex; + flex-direction: column; + gap: $space-4; + font-size: $font-size-md; + line-height: $line-height-normal; + color: $neutral-700; + } + + &__cta { + display: flex; + justify-content: center; + padding: $space-2 0; + } + + &__notice { + display: flex; + flex-direction: column; + gap: $space-3; + padding: $space-5; + border: $border-width-base $border-style-base $neutral-200; + border-radius: $radius-lg; + background-color: $neutral-0; + color: $neutral-900; + + .verification-card--warning &, + .verification-card--danger & { + background-color: rgba($neutral-0, 0.85); + } + } + + &__notice-title { + margin: 0; + font-size: $font-size-md; + font-weight: $font-weight-bold; + line-height: $line-height-tight; + } + + &__notice-text { + margin: 0; + color: $neutral-700; + font-size: $font-size-md; + line-height: $line-height-normal; + } + + &__notice-action { + display: inline-flex; + align-items: center; + gap: $space-2; + align-self: flex-end; + margin-top: $space-2; + padding: $space-2 $space-4; + border-radius: $radius-full; + background-color: $neutral-0; + border: $border-width-base $border-style-base $neutral-200; + color: $color-secondary; + font-size: $font-size-md; + font-weight: $font-weight-semibold; + text-decoration: none; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-50; + border-color: $neutral-300; + } + } + + &__footer { + display: flex; + justify-content: flex-end; + color: $neutral-500; + font-size: $font-size-sm; + line-height: $line-height-tight; + } +} diff --git a/src/scss/demo.scss b/src/scss/demo.scss index 543d242..8c29e50 100644 --- a/src/scss/demo.scss +++ b/src/scss/demo.scss @@ -36,6 +36,14 @@ h3:first-child { margin-top: 0; } + + // Remove vertical margins from subsection headings that sit between + // component blocks in the latest card sections. This prevents the demo + // heading spacing from visually breaking alignment in those screenshots. + h3.demo-subsection-heading { + margin-top: 0; + margin-bottom: 0; + } } .demo-row { diff --git a/src/scss/kit.scss b/src/scss/kit.scss index da4e628..88bc6c0 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -43,9 +43,19 @@ @use "components/dropdown"; @use "components/advanced-select"; @use "components/navigation-shell"; +@use "components/bottom-bar"; +@use "components/top-navbar"; +@use "components/profile-item"; +@use "components/verification-card"; @use "components/option-card"; @use "components/chat"; @use "components/data-row"; +@use "components/campaign-card"; +@use "components/client-card"; +@use "components/folder-card"; +@use "components/chat-input"; +@use "components/step-card"; +@use "components/scheduled-campaign-card"; @use "utils"; * { diff --git a/src/vue/components/GnBottomBar.js b/src/vue/components/GnBottomBar.js new file mode 100644 index 0000000..829a91f --- /dev/null +++ b/src/vue/components/GnBottomBar.js @@ -0,0 +1,130 @@ +/** + * GnBottomBar — floating pill navigation bar. + * + * Figma: Bottom Bar Container. + * + * Supports two layouts: + * stacked - icon above label, all items inside a white pill (mobile nav). + * inline - icon left of label, only the active item is a white pill. + * + * @typedef {Object} GnBottomBarItem + * @property {string} label - Item text. + * @property {string} [icon] - VMK icon key or legacy Phosphor name (ph-*). + * @property {string|Object} [to] - vue-router route. + * @property {string} [href] - Plain link URL. + * @property {string|number} [key] - Stable key used with v-model. + * @property {boolean} [active=false] - Force active state. + * @property {boolean} [disabled=false] - Disable the item. + * + * @slots item - Replace the default label/icon for an item via item slot (scope { item, active }). + */ + +import { computed, defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; +import { tryUseRouter, tryUseRoute, isRouteActive } from "../composables/useVueRouter.js"; + +export default defineComponent({ + name: "GnBottomBar", + inheritAttrs: false, + props: { + items: { type: Array, default: () => [] }, + layout: { type: String, default: "stacked" }, + modelValue: { type: [String, Number], default: undefined }, + activeMatch: { type: String, default: "prefix" } + }, + emits: ["select", "update:modelValue"], + setup(props, { attrs, emit, slots }) { + const router = tryUseRouter(); + const route = tryUseRoute(); + const hasRouter = Boolean(router && route); + + const resolveHref = to => { + if (!to) { + return undefined; + } + + if (typeof to === "string") { + return to; + } + + if (to.path) { + return to.path; + } + + return undefined; + }; + + const isItemActive = (item, index) => { + if (item.to && hasRouter) { + return isRouteActive(route, item.to, props.activeMatch); + } + + if (item.active) { + return true; + } + + if (props.modelValue !== undefined) { + return (item.key !== undefined && item.key === props.modelValue) || index === props.modelValue; + } + + return false; + }; + + const navItems = computed(() => props.items.map((item, index) => { + const hasTo = Boolean(item.to); + const resolvedHref = hasTo + ? (hasRouter ? router.resolve(item.to).href : resolveHref(item.to)) + : item.href; + + return { + ...item, + resolvedHref, + hasTo, + isActive: isItemActive(item, index) + }; + })); + + const activate = (item, index, event) => { + if (item.disabled) { + event.preventDefault(); + return; + } + + if (item.hasTo && hasRouter) { + event.preventDefault(); + router.push(item.to); + } + + if (props.modelValue !== undefined) { + const nextValue = item.key !== undefined ? item.key : index; + emit("update:modelValue", nextValue); + } + + item.onSelect?.(item, event); + emit("select", item); + }; + + return () => h("nav", { + ...attrs, + class: cx("bottom-bar", `bottom-bar--${props.layout}`, attrs.class) + }, navItems.value.map((item, index) => { + const tag = item.resolvedHref ? "a" : "button"; + const isActive = item.isActive; + + return h(tag, { + key: item.key ?? index, + class: cx("bottom-bar__item", { "is-active": isActive, "is-disabled": item.disabled }), + href: item.resolvedHref, + type: item.resolvedHref ? undefined : "button", + disabled: item.disabled || undefined, + "aria-current": isActive ? "page" : undefined, + onClick: event => activate(item, index, event) + }, [ + slots.item?.({ item, active: isActive }) || [ + h("span", { class: "bottom-bar__icon" }, [iconNode(item.icon)]), + h("span", { class: "bottom-bar__label" }, item.label) + ] + ]); + })); + } +}); diff --git a/src/vue/components/GnCampaignCard.js b/src/vue/components/GnCampaignCard.js new file mode 100644 index 0000000..580db50 --- /dev/null +++ b/src/vue/components/GnCampaignCard.js @@ -0,0 +1,49 @@ +/** + * GnCampaignCard — campaign summary card. + * + * Figma: Card - Campaign / Card - Campaign Type. + * + * @typedef {Object} GnCampaignCardMeta + * @property {string} label + * @property {string} value + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnCampaignCard", + inheritAttrs: false, + props: { + icon: { type: String, default: "" }, + title: { type: String, required: true }, + meta: { type: Array, default: () => [] }, + action: { type: String, default: "" }, + variant: { type: String, default: "summary" } // summary | type + }, + emits: ["action"], + setup(props, { attrs, slots, emit }) { + return () => h("article", { + ...attrs, + class: cx("campaign-card", { "campaign-card--type": props.variant === "type" }, attrs.class) + }, [ + h("div", { class: "campaign-card__header" }, [ + h("span", { class: "campaign-card__icon" }, slots.icon?.() || iconNode(props.icon)), + h("h3", { class: "campaign-card__title" }, slots.title?.() || props.title) + ]), + props.variant === "type" && (props.action || slots.text) && h("p", { class: "campaign-card__text" }, slots.text?.() || ""), + (props.meta.length || slots.meta) && h("div", { class: "campaign-card__meta" }, [ + slots.meta?.(), + props.meta.map((item, index) => h("div", { key: index, class: "campaign-card__meta-item" }, [ + h("span", { class: "campaign-card__meta-label" }, item.label), + h("span", { class: "campaign-card__meta-value" }, item.value) + ])) + ]), + (props.action || slots.action) && h("button", { + class: "campaign-card__action", + type: "button", + onClick: () => emit("action") + }, slots.action?.() || props.action) + ]); + } +}); diff --git a/src/vue/components/GnChatInput.js b/src/vue/components/GnChatInput.js new file mode 100644 index 0000000..b024fa4 --- /dev/null +++ b/src/vue/components/GnChatInput.js @@ -0,0 +1,61 @@ +/** + * GnChatInput — rich chat input bar. + * + * Figma: Input Container Chat / Input Container Messenger. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnChatInput", + inheritAttrs: false, + props: { + modelValue: { type: String, default: "" }, + placeholder: { type: String, default: "Type a message…" }, + variant: { type: String, default: "chat" }, // chat | messenger + actions: { type: Array, default: () => [] }, + sendIcon: { type: String, default: "ph-paper-plane-right" }, + disabled: { type: Boolean, default: false } + }, + emits: ["update:modelValue", "send", "action"], + setup(props, { attrs, slots, emit }) { + return () => h("div", { + ...attrs, + class: cx("chat-input", { "chat-input--messenger": props.variant === "messenger" }, attrs.class) + }, [ + slots.prefix?.(), + h("textarea", { + class: "chat-input__field", + rows: 1, + placeholder: props.placeholder, + value: props.modelValue, + disabled: props.disabled, + onInput: event => emit("update:modelValue", event.target.value), + onKeydown: event => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + emit("send", props.modelValue); + } + } + }), + h("div", { class: "chat-input__actions" }, [ + slots.actions?.(), + props.actions.map(action => h("button", { + class: cx("chat-input__action", { [`chat-input__action--${action.type || "secondary"}`]: true }), + type: "button", + "aria-label": action.label, + disabled: props.disabled, + onClick: () => emit("action", action) + }, action.icon && iconNode(action.icon))), + h("button", { + class: cx("chat-input__action", "chat-input__action--send"), + type: "button", + "aria-label": "Send", + disabled: props.disabled, + onClick: () => emit("send", props.modelValue) + }, slots.send?.() || iconNode(props.sendIcon)) + ]) + ]); + } +}); diff --git a/src/vue/components/GnChatMessage.js b/src/vue/components/GnChatMessage.js index ff3e9f2..e7bbfcb 100644 --- a/src/vue/components/GnChatMessage.js +++ b/src/vue/components/GnChatMessage.js @@ -13,6 +13,8 @@ props: { sender: { type: String, default: "user" }, // user | ai title: { type: String, default: "" }, + time: { type: String, default: "" }, + status: { type: String, default: "" }, // sent | delivered | read actions: { type: Array, default: () => [] }, options: { type: Array, default: () => [] } }, @@ -24,6 +26,11 @@ }, [ props.title && h("h4", { class: "chat-message-title" }, props.title), h("div", { class: "chat-bubble" }, slots.default?.()), + h("div", { class: "chat-message-meta" }, [ + slots.meta?.(), + props.time && h("span", { class: "chat-message-time" }, props.time), + props.status && h("span", { class: "chat-message-status" }, props.status) + ]), (props.actions.length || slots.actions) && h("div", { class: "chat-message-actions" }, [ slots.actions?.(), props.actions.map(action => h("button", { diff --git a/src/vue/components/GnClientCard.js b/src/vue/components/GnClientCard.js new file mode 100644 index 0000000..b8c29ec --- /dev/null +++ b/src/vue/components/GnClientCard.js @@ -0,0 +1,54 @@ +/** + * GnClientCard — client summary card. + * + * Figma: Client Card. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnClientCard", + inheritAttrs: false, + props: { + name: { type: String, required: true }, + role: { type: String, default: "" }, + image: { type: String, default: "" }, + icon: { type: String, default: "ph-user" }, + details: { type: Array, default: () => [] } + }, + emits: ["menu"], + setup(props, { attrs, slots, emit }) { + return () => h("article", { + ...attrs, + class: cx("client-card", attrs.class) + }, [ + h("div", { class: "client-card__header" }, [ + h("div", { class: "client-card__main" }, [ + h("span", { class: "client-card__avatar" }, [ + slots.avatar?.() || (props.image + ? h("img", { src: props.image, alt: "" }) + : iconNode(props.icon)) + ]), + h("div", { class: "client-card__info" }, [ + h("h3", { class: "client-card__name" }, slots.name?.() || props.name), + (props.role || slots.role) && h("span", { class: "client-card__role" }, slots.role?.() || props.role) + ]) + ]), + h("button", { + class: "client-card__menu", + type: "button", + "aria-label": "More options", + onClick: () => emit("menu") + }, slots.menu?.() || iconNode("ph-dots-three-vertical")) + ]), + (props.details.length || slots.details) && h("div", { class: "client-card__details" }, [ + slots.details?.(), + props.details.map((item, index) => h("div", { key: index, class: "client-card__detail" }, [ + h("span", { class: "client-card__detail-label" }, item.label), + h("span", { class: "client-card__detail-value" }, item.value) + ])) + ]) + ]); + } +}); diff --git a/src/vue/components/GnDataRow.js b/src/vue/components/GnDataRow.js index 3e87d1a..14ad8c8 100644 --- a/src/vue/components/GnDataRow.js +++ b/src/vue/components/GnDataRow.js @@ -1,7 +1,17 @@ /** * GnDataRow — compact data table/list row. * - * Figma: Row - Asset List / Order list / Audience contact list. + * Figma: Row - Asset List / Order list / Audience contact list, + * Campain search card / Contact search card. + * + * @typedef {Object} GnDataRowColumn + * @property {string} value - Cell value. + * @property {string} [label] - Optional column label. + * @property {boolean} [main=false] - Whether this cell expands. + * + * @typedef {Object} GnDataRowMetric + * @property {string} value - Metric value text. + * @property {string} [icon] - VMK icon key or legacy Phosphor name (ph-*). */ import { defineComponent, h } from "vue"; @@ -15,10 +25,35 @@ image: { type: String, default: "" }, icon: { type: String, default: "" }, columns: { type: Array, default: () => [] }, + meta: { type: Array, default: () => [] }, + metrics: { type: Array, default: () => [] }, actions: { type: Array, default: () => [] } }, emits: ["select", "action"], setup(props, { attrs, slots, emit }) { + const renderMeta = () => { + const items = props.meta; + if (!items.length) { + return null; + } + + return h("span", { class: "data-row-meta" }, items.map((item, index) => { + const nodes = []; + + if (index > 0) { + nodes.push(h("span", { class: "data-row-meta-separator" })); + } + + if (item.icon) { + nodes.push(iconNode(item.icon)); + } + + nodes.push(h("span", null, item.label || item.value || item)); + + return h("span", { key: index, class: "data-row-meta-item" }, nodes); + })); + }; + return () => h("div", { ...attrs, class: cx("data-row", { "data-row-selected": props.selected }, attrs.class) @@ -30,19 +65,29 @@ onChange: event => emit("select", event.target.checked) }) ]), - (props.image || props.icon || slots.media) && h("div", { class: "data-row-media" }, [ + (props.image || props.icon || slots.media) && h("div", { + class: cx("data-row-media", { "data-row-media--icon": props.icon && !props.image }) + }, [ slots.media?.(), props.image && !slots.media && h("img", { src: props.image, alt: "" }), props.icon && !props.image && !slots.media && iconNode(props.icon) ]), - h("div", { class: "data-row-content" }, - props.columns.map((col, index) => h("div", { + h("div", { class: "data-row-content" }, [ + ...props.columns.map((col, index) => h("div", { class: cx("data-row-cell", { "data-row-cell-main": col.main }) }, [ col.label && h("span", { class: "data-row-label" }, col.label), h("span", { class: "data-row-value" }, slots[`col-${index}`]?.({ col }) || col.value) + ])), + slots.meta?.() || renderMeta() + ]), + (props.metrics.length || slots.metrics) && h("div", { class: "data-row-metrics" }, [ + slots.metrics?.(), + props.metrics.map(metric => h("span", { class: "data-row-metric" }, [ + metric.icon && iconNode(metric.icon), + metric.value ])) - ), + ]), (props.actions.length || slots.actions) && h("div", { class: "data-row-actions" }, [ slots.actions?.(), props.actions.map(action => h("button", { diff --git a/src/vue/components/GnElementCard.js b/src/vue/components/GnElementCard.js new file mode 100644 index 0000000..00639ca --- /dev/null +++ b/src/vue/components/GnElementCard.js @@ -0,0 +1,60 @@ +/** + * GnElementCard — compact vertical icon-label tile. + * + * Figma: Element Card. + * + * @typedef {Object} GnElementCardProps + * @property {string} label + * @property {string} [icon] - VMK icon key or legacy Phosphor name (ph-*). + * @property {string} [badge] - Optional small badge text (e.g. "Pro"). + * @property {boolean} [selected=false] + * @property {boolean} [disabled=false] + * @property {string} [href=''] + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnElementCard", + inheritAttrs: false, + props: { + label: { type: String, required: true }, + icon: { type: String, default: "" }, + badge: { type: String, default: "" }, + selected: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + href: { type: String, default: "" } + }, + emits: ["select", "update:selected"], + setup(props, { attrs, slots, emit }) { + return () => { + const tag = props.href ? "a" : "button"; + + return h(tag, { + ...attrs, + class: cx( + "element-card", + { + "is-selected": props.selected, + "is-disabled": props.disabled + }, + attrs.class + ), + href: props.href || undefined, + type: props.href ? undefined : "button", + disabled: props.disabled || undefined, + onClick: () => { + if (!props.disabled) { + emit("update:selected", !props.selected); + emit("select"); + } + } + }, [ + props.badge && h("span", { class: "element-card__badge" }, props.badge), + h("span", { class: "element-card__icon" }, slots.icon?.() || iconNode(props.icon)), + h("span", { class: "element-card__label" }, slots.label?.() || props.label) + ]); + }; + } +}); diff --git a/src/vue/components/GnFolderCard.js b/src/vue/components/GnFolderCard.js new file mode 100644 index 0000000..95b2517 --- /dev/null +++ b/src/vue/components/GnFolderCard.js @@ -0,0 +1,70 @@ +/** + * GnFolderCard — folder card with icon, title, menu and summary. + * + * Figma: Card - Folder / Card - Folder Option. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnFolderCard", + inheritAttrs: false, + props: { + title: { type: String, required: true }, + image: { type: String, default: "" }, + icon: { type: String, default: "ph-folder" }, + layout: { type: String, default: "vertical" }, // vertical | horizontal + selected: { type: Boolean, default: false }, + summary: { type: Array, default: () => [] } + }, + emits: ["select", "update:selected", "menu"], + setup(props, { attrs, slots, emit }) { + return () => { + const tag = props.selected !== undefined && !attrs.href ? "button" : "article"; + + return h(tag, { + ...attrs, + class: cx( + "folder-card", + { "folder-card--horizontal": props.layout === "horizontal" }, + { "is-selected": props.selected }, + attrs.class + ), + type: tag === "button" ? "button" : undefined, + onClick: () => { + emit("update:selected", !props.selected); + emit("select"); + } + }, [ + h("button", { + class: "folder-card__menu", + type: "button", + "aria-label": "More options", + onClick: event => { + event.stopPropagation(); + emit("menu"); + } + }, slots.menu?.() || iconNode("ph-dots-three-vertical")), + h("div", { class: "folder-card__main" }, [ + h("span", { class: "folder-card__icon" }, [ + slots.icon?.() || (props.image + ? h("img", { src: props.image, alt: "" }) + : iconNode(props.icon)) + ]), + h("h3", { class: "folder-card__title" }, slots.title?.() || props.title) + ]), + (props.summary.length || slots.summary) && h("div", { class: "folder-card__summary" }, [ + slots.summary?.(), + props.summary.map((item, index) => h("div", { + key: index, + class: cx("folder-card__summary-item", { "folder-card__summary-item--full": item.full }) + }, [ + h("span", { class: "folder-card__summary-label" }, item.label), + h("span", { class: "folder-card__summary-value" }, item.value) + ])) + ]) + ]); + }; + } +}); diff --git a/src/vue/components/GnOptionCard.js b/src/vue/components/GnOptionCard.js index 81dc506..95492a5 100644 --- a/src/vue/components/GnOptionCard.js +++ b/src/vue/components/GnOptionCard.js @@ -13,18 +13,24 @@ inheritAttrs: false, props: { layout: { type: String, default: "vertical" }, // vertical | horizontal + size: { type: String, default: "md" }, // md | lg image: { type: String, default: "" }, + icon: { type: String, default: "" }, match: { type: [String, Number], default: "" }, price: { type: String, default: "" }, area: { type: String, default: "" }, title: { type: String, default: "" }, + description: { type: String, default: "" }, badges: { type: Array, default: () => [] }, expanded: { type: Boolean, default: false }, stats: { type: Array, default: () => [] }, details: { type: Array, default: () => [] }, - actions: { type: Array, default: () => [] } + actions: { type: Array, default: () => [] }, + selectable: { type: Boolean, default: false }, + selected: { type: Boolean, default: false }, + indicator: { type: String, default: "radio" } // radio | check }, - emits: ["action", "select"], + emits: ["action", "select", "update:selected"], setup(props, { attrs, slots, emit }) { const mediaNode = () => h("div", { class: "option-card-media" }, [ props.image @@ -78,26 +84,45 @@ ]))) ]); - return () => h("article", { - ...attrs, - class: cx( - "option-card", - `option-card-${props.layout}`, - { "option-card-expanded": props.expanded }, - attrs.class - ), - onClick: () => emit("select") - }, [ - mediaNode(), - h("div", { class: "option-card-body" }, [ - headerNode(), - props.title && h("h3", { class: "option-card-title" }, props.title), - slots.default?.(), - badgesNode(), - actionsNode() - ]), - statsNode(), - detailsNode() - ]); + return () => { + const hasIcon = Boolean(props.icon) || slots.icon; + + return h("article", { + ...attrs, + class: cx( + "option-card", + `option-card-${props.layout}`, + `option-card--${props.size}`, + { + "option-card-expanded": props.expanded, + "option-card--selectable": props.selectable, + "is-selected": props.selected + }, + attrs.class + ), + role: props.selectable ? "option" : undefined, + "aria-selected": props.selectable ? String(props.selected) : undefined, + onClick: () => { + if (props.selectable) { + emit("update:selected", !props.selected); + } + emit("select"); + } + }, [ + props.selectable && h("span", { class: cx("option-card__indicator", `option-card__indicator--${props.indicator === "check" ? "check" : "radio"}`) }), + mediaNode(), + h("div", { class: "option-card-body" }, [ + hasIcon && h("span", { class: "option-card__icon" }, slots.icon?.() || iconNode(props.icon)), + headerNode(), + props.title && h("h3", { class: "option-card-title" }, props.title), + props.description && h("p", { class: "option-card-description" }, props.description), + slots.default?.(), + badgesNode(), + actionsNode() + ]), + statsNode(), + detailsNode() + ]); + }; } }); diff --git a/src/vue/components/GnProfileItem.js b/src/vue/components/GnProfileItem.js new file mode 100644 index 0000000..42a7cc0 --- /dev/null +++ b/src/vue/components/GnProfileItem.js @@ -0,0 +1,70 @@ +/** + * GnProfileItem — horizontal rounded action row. + * + * Figma: Profile. + * + * @typedef {Object} GnProfileItemProps + * @property {string} title + * @property {string} [meta=''] + * @property {string} [icon=''] - VMK icon key or legacy Phosphor name (ph-*). + * @property {string} [image=''] - Avatar/image URL (takes precedence over icon). + * @property {boolean} [danger=false] - Danger/destuctive theme. + * @property {boolean} [disabled=false] + * @property {string} [href=''] - Renders as link when provided. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnProfileItem", + inheritAttrs: false, + props: { + title: { type: String, required: true }, + meta: { type: String, default: "" }, + icon: { type: String, default: "" }, + image: { type: String, default: "" }, + danger: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + href: { type: String, default: "" } + }, + emits: ["click"], + setup(props, { attrs, slots, emit }) { + return () => { + const tag = props.href ? "a" : "button"; + + return h(tag, { + ...attrs, + class: cx( + "profile-item", + { + "is-danger": props.danger, + "is-disabled": props.disabled + }, + attrs.class + ), + href: props.href || undefined, + type: props.href ? undefined : "button", + disabled: props.disabled || undefined, + onClick: event => { + if (!props.disabled) { + emit("click", event); + } + } + }, [ + h("div", { class: "profile-item__main" }, [ + h("span", { class: "profile-item__icon" }, [ + slots.icon?.() || (props.image + ? h("img", { src: props.image, alt: "" }) + : iconNode(props.icon)) + ]), + h("div", { class: "profile-item__content" }, [ + h("span", { class: "profile-item__title" }, slots.title?.() || props.title), + (props.meta || slots.meta) && h("span", { class: "profile-item__meta" }, slots.meta?.() || props.meta) + ]) + ]), + h("span", { class: "profile-item__chevron" }, slots.chevron?.() || iconNode("ph-chevron-right")) + ]); + }; + } +}); diff --git a/src/vue/components/GnScheduledCampaignCard.js b/src/vue/components/GnScheduledCampaignCard.js new file mode 100644 index 0000000..f602759 --- /dev/null +++ b/src/vue/components/GnScheduledCampaignCard.js @@ -0,0 +1,37 @@ +/** + * GnScheduledCampaignCard — scheduled campaign summary card. + * + * Figma: Card - Scheduled Campaign. + */ + +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnScheduledCampaignCard", + inheritAttrs: false, + props: { + title: { type: String, required: true }, + status: { type: String, default: "scheduled" }, // scheduled | sent | draft + meta: { type: Array, default: () => [] } // e.g. ["Email", "All subscribers"] + }, + setup(props, { attrs, slots }) { + return () => { + const metaText = slots.meta?.() + ? null + : (props.meta || []).filter(Boolean).join(" • "); + + return h("article", { + ...attrs, + class: cx( + "scheduled-campaign-card", + `scheduled-campaign-card--${props.status}`, + attrs.class + ) + }, [ + h("h3", { class: "scheduled-campaign-card__title" }, slots.title?.() || props.title), + (metaText || slots.meta) && h("p", { class: "scheduled-campaign-card__meta" }, slots.meta?.() || metaText) + ]); + }; + } +}); diff --git a/src/vue/components/GnStepCard.js b/src/vue/components/GnStepCard.js new file mode 100644 index 0000000..78f175d --- /dev/null +++ b/src/vue/components/GnStepCard.js @@ -0,0 +1,49 @@ +/** + * GnStepCard — step card. + * + * Figma: Card - Step. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnStepCard", + inheritAttrs: false, + props: { + step: { type: Number, required: true }, + total: { type: Number, required: true }, + title: { type: String, required: true }, + description: { type: String, default: "" }, + done: { type: Boolean, default: false }, + actionLabel: { type: String, default: "" } + }, + emits: ["action"], + setup(props, { attrs, slots, emit }) { + return () => { + const badgeText = `Step ${props.step}`; + const isDone = props.done; + const label = slots.action?.() + ? null + : (props.actionLabel || (isDone ? "Done" : "Get started")); + + return h("article", { + ...attrs, + class: cx("step-card", attrs.class) + }, [ + h("span", { class: "step-card__badge" }, slots.badge?.() || badgeText), + h("h3", { class: "step-card__title" }, slots.title?.() || props.title), + (props.description || slots.description) && h("p", { class: "step-card__description" }, + slots.description?.() || props.description), + (label || slots.action) && h("button", { + class: cx("step-card__action", { "step-card__action--done": isDone }), + type: "button", + onClick: () => emit("action") + }, slots.action?.() || [ + isDone && iconNode("ph-check-circle", "step-card__action-icon"), + label + ]) + ]); + }; + } +}); diff --git a/src/vue/components/GnTopNavbar.js b/src/vue/components/GnTopNavbar.js new file mode 100644 index 0000000..8006836 --- /dev/null +++ b/src/vue/components/GnTopNavbar.js @@ -0,0 +1,158 @@ +/** + * GnTopNavbar — application-level top header bar. + * + * Figma: Top Navbar. + * + * Variants: + * ghost - transparent over dark surfaces (logo + center nav + pill action). + * solid - white bar with title/actions (back buttons, outline actions). + * centered - centered logo/brand only. + * compact - mobile-style bar with back arrow, title/meta, and optional action. + * + * @typedef {Object} GnTopNavbarBrand + * @property {string} title - Brand text. + * @property {boolean} [logo=true] - Show the default logo orb. + * + * @typedef {Object} GnTopNavbarAction + * @property {string} label - Action text. + * @property {string} [icon] - VMK icon key or legacy Phosphor name (ph-*). + * @property {string} [href] - Link URL. + * @property {string} [variant] - "pill" (white pill) or "outline" (mint outline). Ghost defaults to pill. + * @property {Function} [onClick] - Click handler. + * + * @slots brand - Left brand region. + * @slots center - Center region (overrides title/navItems). + * @slots actions - Right actions region (overrides actions prop). + */ + +import { defineComponent, h, ref, watch } from "vue"; +import { cx, iconNode } from "../utils.js"; +import GnBottomBar from "./GnBottomBar.js"; + +export default defineComponent({ + name: "GnTopNavbar", + inheritAttrs: false, + props: { + variant: { type: String, default: "ghost" }, + brand: { type: [String, Object], default: "" }, + title: { type: String, default: "" }, + subtitle: { type: String, default: "" }, + navItems: { type: Array, default: () => [] }, + actions: { type: Array, default: () => [] }, + back: { type: Boolean, default: false }, + activeNav: { type: [String, Number], default: undefined } + }, + emits: ["select", "back"], + setup(props, { attrs, slots, emit }) { + const centerActive = ref(props.activeNav ?? (props.navItems.length ? 0 : undefined)); + + watch(() => props.activeNav, value => { + if (value !== undefined) { + centerActive.value = value; + } + }); + + const brandTitle = typeof props.brand === "string" ? props.brand : props.brand?.title; + const showLogo = typeof props.brand === "object" ? props.brand?.logo !== false : true; + + const renderBrand = () => { + if (slots.brand) { + return slots.brand(); + } + + if (!brandTitle) { + return null; + } + + return h("a", { class: "top-navbar__brand", href: "#" }, [ + showLogo && h("span", { class: "top-navbar__logo" }), + h("span", { class: "top-navbar__title" }, brandTitle) + ]); + }; + + const renderCenterContent = () => { + if (slots.center) { + return slots.center(); + } + + if (props.title) { + return h("h1", { class: "top-navbar__title" }, props.title); + } + + if (props.navItems.length) { + return h(GnBottomBar, { + class: "top-navbar__nav", + layout: "inline", + items: props.navItems, + modelValue: centerActive.value, + "onUpdate:modelValue": value => { + centerActive.value = value; + emit("select", value); + } + }); + } + + return null; + }; + + const renderCenter = () => { + const content = [renderCenterContent()]; + + if (props.subtitle) { + content.push(h("span", { class: "top-navbar__meta" }, props.subtitle)); + } + + const stack = props.subtitle && !slots.center; + + return h("div", { class: cx("top-navbar__center", { "top-navbar__center--stack": stack }) }, content); + }; + + const renderActions = () => { + if (slots.actions) { + return slots.actions(); + } + + return props.actions.map((action, index) => { + const tag = action.href ? "a" : "button"; + const isPill = action.variant === "pill" || (props.variant === "ghost" && action.variant !== "outline"); + + return h(tag, { + key: index, + class: isPill ? "top-navbar__pill" : cx("btn", "btn-secondary", "btn-sm"), + href: action.href, + type: action.href ? undefined : "button", + onClick: () => action.onClick?.(action) + }, [ + iconNode(action.icon), + action.label + ]); + }); + }; + + return () => { + const children = []; + + if (props.variant === "centered") { + children.push(h("div", { class: "top-navbar__center" }, slots.center?.() ?? renderBrand())); + } else { + children.push(renderBrand()); + + if (props.back) { + children.push(h("button", { + class: cx("btn", "btn-icon-only", "btn-sm", "btn-secondary", "top-navbar__back"), + type: "button", + onClick: () => emit("back") + }, [iconNode("ph-arrow-left")])); + } + + children.push(renderCenter()); + children.push(h("div", { class: "top-navbar__actions" }, [renderActions()])); + } + + return h("header", { + ...attrs, + class: cx("top-navbar", `top-navbar--${props.variant}`, attrs.class) + }, children); + }; + } +}); diff --git a/src/vue/components/GnVerificationCard.js b/src/vue/components/GnVerificationCard.js new file mode 100644 index 0000000..f6efcb0 --- /dev/null +++ b/src/vue/components/GnVerificationCard.js @@ -0,0 +1,84 @@ +/** + * GnVerificationCard — verification status panel. + * + * Figma: Characteristics Container. + * + * @typedef {Object} GnVerificationCardProps + * @property {string} title + * @property {string} [status='verified'] - verified | warning | danger + * @property {string} [statusText=''] + * @property {Array<{label:string,icon?:string,checked?:boolean}>} [items=[]] + * @property {string} [body=''] + * @property {string} [cta=''] + * @property {Function} [onCta] + * @property {Object} [notice] - { title, text, action, onAction } + * @property {string} [timestamp=''] + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnVerificationCard", + inheritAttrs: false, + props: { + title: { type: String, required: true }, + status: { type: String, default: "verified" }, + statusText: { type: String, default: "" }, + items: { type: Array, default: () => [] }, + body: { type: String, default: "" }, + cta: { type: String, default: "" }, + notice: { type: Object, default: null }, + timestamp: { type: String, default: "" } + }, + emits: ["cta", "notice-action"], + setup(props, { attrs, slots, emit }) { + const statusMap = { + verified: "ph-check-circle", + warning: "ph-warning-circle", + danger: "ph-warning-circle" + }; + + return () => h("article", { + ...attrs, + class: cx("verification-card", `verification-card--${props.status}`, attrs.class) + }, [ + h("div", { class: "verification-card__header" }, [ + h("h3", { class: "verification-card__title" }, slots.title?.() || props.title), + (props.statusText || slots.status) && h("span", { class: "verification-card__status" }, [ + iconNode(statusMap[props.status] || "ph-check-circle"), + slots.status?.() || props.statusText + ]) + ]), + (props.items.length || slots.items) && h("ul", { class: "verification-card__list" }, [ + props.items.map(item => h("li", { class: "verification-card__item" }, [ + iconNode(item.icon || (item.checked !== false ? "ph-check-circle" : "ph-warning-circle")), + item.label + ])), + slots.items?.() + ]), + (props.body || slots.body) && h("div", { class: "verification-card__body" }, slots.body?.() || props.body), + (props.cta || slots.cta) && h("div", { class: "verification-card__cta" }, [ + h("button", { + class: cx("btn", "btn-secondary", "btn-sm"), + type: "button", + onClick: () => emit("cta") + }, slots.cta?.() || props.cta) + ]), + (props.notice || slots.notice) && h("div", { class: "verification-card__notice" }, [ + props.notice?.title && h("h4", { class: "verification-card__notice-title" }, props.notice.title), + props.notice?.text && h("p", { class: "verification-card__notice-text" }, props.notice.text), + props.notice?.action && h("button", { + class: "verification-card__notice-action", + type: "button", + onClick: () => emit("notice-action", props.notice) + }, [ + props.notice.action, + iconNode("ph-chevron-right") + ]), + slots.notice?.() + ]), + (props.timestamp || slots.timestamp) && h("footer", { class: "verification-card__footer" }, slots.timestamp?.() || props.timestamp) + ]); + } +}); diff --git a/src/vue/components/Icon.js b/src/vue/components/Icon.js index 7598706..4e2db24 100644 --- a/src/vue/components/Icon.js +++ b/src/vue/components/Icon.js @@ -13,7 +13,8 @@ inheritAttrs: false, props: { name: { type: String, default: "" }, - spin: { type: Boolean, default: false } + spin: { type: Boolean, default: false }, + phName: { type: String, default: "" } }, setup(props, { attrs }) { const svg = ref(""); @@ -31,6 +32,17 @@ return () => { if (!props.name) return null; + + // If the VMK SVG is missing and we know the Phosphor name, + // fall back to the Phosphor icon font glyph. + if (!svg.value && props.phName) { + return h("i", { + ...attrs, + class: ["ph", `ph-${props.phName}`, attrs.class], + "aria-hidden": "true" + }); + } + return h("span", { ...attrs, class: ["vmk-icon", { "is-spinning": props.spin }, attrs.class], diff --git a/src/vue/index.js b/src/vue/index.js index 82a7269..4a67a76 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -55,6 +55,10 @@ export { default as GnDropdownItem } from "./components/GnDropdownItem.js"; export { default as GnNavigationShell } from "./components/GnNavigationShell.js"; export { default as GnNavigationItem } from "./components/GnNavigationItem.js"; +export { default as GnBottomBar } from "./components/GnBottomBar.js"; +export { default as GnTopNavbar } from "./components/GnTopNavbar.js"; +export { default as GnProfileItem } from "./components/GnProfileItem.js"; +export { default as GnVerificationCard } from "./components/GnVerificationCard.js"; export { default as GnBackground } from "./components/GnBackground.js"; export { default as GnBreadcrumbs } from "./components/GnBreadcrumbs.js"; export { default as GnBreadcrumbItem } from "./components/GnBreadcrumbItem.js"; @@ -69,7 +73,13 @@ export { default as GnHorizontalCard } from "./components/GnHorizontalCard.js"; export { default as GnOptionCard } from "./components/GnOptionCard.js"; export { default as GnDataRow } from "./components/GnDataRow.js"; +export { default as GnCampaignCard } from "./components/GnCampaignCard.js"; +export { default as GnClientCard } from "./components/GnClientCard.js"; +export { default as GnFolderCard } from "./components/GnFolderCard.js"; +export { default as GnChatInput } from "./components/GnChatInput.js"; export { default as GnChatMessage } from "./components/GnChatMessage.js"; +export { default as GnStepCard } from "./components/GnStepCard.js"; +export { default as GnScheduledCampaignCard } from "./components/GnScheduledCampaignCard.js"; export { default as GnUserCard } from "./components/GnUserCard.js"; export { default as GnMetricCard } from "./components/GnMetricCard.js"; export { default as GnStatusCard } from "./components/GnStatusCard.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index c787824..90e1b6d 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -52,6 +52,10 @@ import GnDropdownItem from "./components/GnDropdownItem.js"; import GnNavigationShell from "./components/GnNavigationShell.js"; import GnNavigationItem from "./components/GnNavigationItem.js"; +import GnBottomBar from "./components/GnBottomBar.js"; +import GnTopNavbar from "./components/GnTopNavbar.js"; +import GnProfileItem from "./components/GnProfileItem.js"; +import GnVerificationCard from "./components/GnVerificationCard.js"; import GnBackground from "./components/GnBackground.js"; import GnBreadcrumbs from "./components/GnBreadcrumbs.js"; import GnBreadcrumbItem from "./components/GnBreadcrumbItem.js"; @@ -69,7 +73,13 @@ import GnHorizontalCard from "./components/GnHorizontalCard.js"; import GnOptionCard from "./components/GnOptionCard.js"; import GnDataRow from "./components/GnDataRow.js"; +import GnCampaignCard from "./components/GnCampaignCard.js"; +import GnClientCard from "./components/GnClientCard.js"; +import GnFolderCard from "./components/GnFolderCard.js"; +import GnChatInput from "./components/GnChatInput.js"; import GnChatMessage from "./components/GnChatMessage.js"; +import GnStepCard from "./components/GnStepCard.js"; +import GnScheduledCampaignCard from "./components/GnScheduledCampaignCard.js"; import GnUserCard from "./components/GnUserCard.js"; import GnMetricCard from "./components/GnMetricCard.js"; import GnStatusCard from "./components/GnStatusCard.js"; @@ -131,6 +141,10 @@ GnDropdownItem, GnNavigationShell, GnNavigationItem, + GnBottomBar, + GnTopNavbar, + GnProfileItem, + GnVerificationCard, GnBackground, GnBreadcrumbs, GnBreadcrumbItem, @@ -148,7 +162,13 @@ GnHorizontalCard, GnOptionCard, GnDataRow, + GnCampaignCard, + GnClientCard, + GnFolderCard, + GnChatInput, GnChatMessage, + GnStepCard, + GnScheduledCampaignCard, GnUserCard, GnMetricCard, GnStatusCard, diff --git a/src/vue/utils.js b/src/vue/utils.js index d4bb38f..8a355b3 100644 --- a/src/vue/utils.js +++ b/src/vue/utils.js @@ -113,6 +113,7 @@ "chevron-down": "arrows/chevron/down", "magnifying-glass": "essentials/zoom/search", search: "essentials/zoom/search", + megaphone: "essentials/megaphone", "warning-circle": "essentials/alert/circle", plus: "essentials/plus", minus: "essentials/minus", @@ -139,7 +140,21 @@ edit: "software/edit/1", house: "buildings-&-landmarks/house/1", home: "buildings-&-landmarks/house/1", + "paper-plane": "essentials/send", + "paper-plane-tilt": "essentials/send", + sparkle: "essentials/star", + envelope: "messages/mail", + "envelope-simple": "messages/mail", + users: "essentials/users", list: "essentials/list-fill", + folder: "documents-&-safety/folder", + "folder-open": "documents-&-safety/folder", + "dots-three": "menus/dots/3", + "dots-three-vertical": "menus/dots/3-vertical", + "paper-plane-right": "essentials/send", + "paperclip": "documents-&-safety/paperclip", + "smiley": "emojis/smile", + "calendar-blank": "time/calendar", "credit-card": "banking-&-finance/credit-card", "chart-bar": "banking-&-finance/chart/bars", bell: "essentials/notification/1", @@ -175,10 +190,12 @@ } let name = icon.trim(); + let phName = ""; // Accept legacy Phosphor prefixes (ph-plus, ph bold ph-spinner, etc). if (PHOSPHOR_PREFIX_RE.test(name)) { - name = name.slice(3).trim(); + phName = name.slice(3).trim(); + name = phName; } // If the name already looks like a VMK key, use it directly. @@ -188,6 +205,7 @@ return h(VmkIcon, { name, + phName, class: extraClass }); } diff --git a/tests/demo-screenshot.spec.js b/tests/demo-screenshot.spec.js index 898064b..ef8ca70 100644 --- a/tests/demo-screenshot.spec.js +++ b/tests/demo-screenshot.spec.js @@ -45,7 +45,17 @@ { name: "avatar", label: "Avatar" }, { name: "skeleton", label: "Skeleton" }, { name: "navigation-shell", label: "Navigation Shell" }, - { name: "option-card-chat", label: "Option Card & Chat" } + { name: "bottom-bar", label: "Bottom Bar" }, + { name: "top-navbar", label: "Top Navbar" }, + { name: "profile-item", label: "Profile Item" }, + { name: "verification-card", label: "Verification Card" }, + { name: "data-row", label: "Data Row" }, + { name: "element-card", label: "Element Card" }, + { name: "option-card-chat", label: "Option Card & Chat" }, + { name: "campaign-cards", label: "Campaign Cards" }, + { name: "client-folder-cards", label: "Client & Folder Cards" }, + { name: "chat-inputs", label: "Chat Inputs" }, + { name: "step-cards", label: "Step & Scheduled Campaign Cards" } ]; test.describe("@screenshot", () => {