Browse Source

Feature/add 3D hover effect to membership card component (#5966)

* Add 3D hover effect to membership card component

* Update changelog
pull/6015/head^2
David Requeno 2 days ago
committed by GitHub
parent
commit
bca5ce3f04
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 94
      libs/ui/src/lib/membership-card/membership-card.component.html
  3. 244
      libs/ui/src/lib/membership-card/membership-card.component.scss
  4. 5
      libs/ui/src/lib/membership-card/membership-card.component.stories.ts
  5. 1
      libs/ui/src/lib/membership-card/membership-card.component.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
#### Added #### Added
- Introduced data source transformation support in the import functionality for self-hosted environments - Introduced data source transformation support in the import functionality for self-hosted environments
- Added an optional 3D hover effect to the membership card component
#### Changed #### Changed

94
libs/ui/src/lib/membership-card/membership-card.component.html

@ -1,50 +1,54 @@
<div <div class="card-wrapper position-relative" [class.hover-3d]="hover3d">
class="card-container position-relative" <div class="card-container" [ngClass]="{ premium: name === 'Premium' }">
[ngClass]="{ premium: name === 'Premium' }" <a
> class="card-item d-flex flex-column justify-content-between p-4"
<a [routerLink]="routerLinkPricing"
class="card-item d-flex flex-column justify-content-between p-4" >
[routerLink]="routerLinkPricing" @if (hover3d) {
> @for (zone of [1, 2, 3, 4, 5, 6, 7, 8, 9]; track zone) {
<div class="d-flex justify-content-end"> <span class="hover-zone position-absolute"></span>
<gf-logo }
size="large" }
[ngClass]="{ 'text-muted': name === 'Basic' }" <div class="d-flex justify-content-end">
[showLabel]="false" <gf-logo
/> size="large"
</div> [ngClass]="{ 'text-muted': name === 'Basic' }"
@if (hasPermissionToCreateApiKey) { [showLabel]="false"
<div class="mt-5"> />
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
</div> </div>
@if (expiresAt) { @if (hasPermissionToCreateApiKey) {
<div> <div class="mt-5">
<div class="heading text-muted" i18n>Valid until</div> <div class="heading text-muted" i18n>API Key</div>
<div class="text-truncate value"> <div class="align-items-center d-flex">
{{ expiresAt }} <div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="bg-transparent no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div> </div>
</div> </div>
} }
</div> <div class="d-flex justify-content-between">
</a> <div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
</div>
@if (expiresAt) {
<div>
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
</div>
</div>
}
</div>
</a>
</div>
</div> </div>

244
libs/ui/src/lib/membership-card/membership-card.component.scss

@ -1,71 +1,231 @@
:host { :host {
--borderRadius: 1rem; --borderRadius: 1rem;
--borderWidth: 2px; --borderWidth: 2px;
--hover3dSpotlightOpacity: 0.2;
display: block; display: block;
max-width: 25rem; max-width: 25rem;
padding-top: calc(1 * var(--borderWidth)); padding-top: calc(1 * var(--borderWidth));
width: 100%; width: 100%;
.card-container { .card-wrapper {
border-radius: var(--borderRadius); &.hover-3d {
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15); perspective: 1000px;
}
&:after { .card-container {
animation: animatedborder 7s ease alternate infinite;
background: linear-gradient(60deg, #5073b8, #1098ad, #07b39b, #6fba82);
background-size: 300% 300%;
border-radius: var(--borderRadius); border-radius: var(--borderRadius);
content: ''; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
height: calc(100% + var(--borderWidth) * 2);
left: calc(-1 * var(--borderWidth)); .card-item {
top: calc(-1 * var(--borderWidth)); aspect-ratio: 1.586;
position: absolute; background-color: #1d2124;
width: calc(100% + var(--borderWidth) * 2); border-radius: calc(var(--borderRadius) - var(--borderWidth));
z-index: -1; color: rgba(var(--light-primary-text));
line-height: 1.2;
@keyframes animatedborder {
0% { button {
background-position: 0% 50%; color: rgba(var(--light-primary-text));
height: 1.5rem;
z-index: 3;
} }
50% {
background-position: 100% 50%; .heading {
font-size: 13px;
} }
100% {
background-position: 0% 50%; .value {
font-size: 18px;
}
}
&:not(.premium) {
&::after {
opacity: 0;
}
.card-item {
background-color: #ffffff;
color: rgba(var(--dark-primary-text));
} }
} }
} }
.card-item { &.hover-3d {
aspect-ratio: 1.586; --hover3d-rotate-x: 0;
background-color: #1d2124; --hover3d-rotate-y: 0;
border-radius: calc(var(--borderRadius) - var(--borderWidth)); --hover3d-shine: 100% 100%;
color: rgba(var(--light-primary-text));
line-height: 1.2;
button { .card-container {
color: rgba(var(--light-primary-text)); overflow: hidden;
height: 1.5rem; position: relative;
scale: 1;
transform: rotate3d(
var(--hover3d-rotate-x),
var(--hover3d-rotate-y),
0,
10deg
);
transform-style: preserve-3d;
transition:
box-shadow 400ms ease-out,
scale 500ms ease-out,
transform 500ms ease-out;
will-change: transform, scale;
&::before {
background-image: radial-gradient(
circle at 50%,
rgba(255, 255, 255, var(--hover3dSpotlightOpacity)) 10%,
transparent 50%
);
content: '';
filter: blur(0.75rem);
height: 33.333%;
opacity: 0;
pointer-events: none;
position: absolute;
scale: 500%;
translate: var(--hover3d-shine);
transition:
opacity 400ms ease-out,
translate 400ms ease-out;
width: 33.333%;
z-index: 1;
}
.card-item {
position: relative;
.hover-zone {
height: 33.333%;
width: 33.333%;
z-index: 2;
&:nth-child(1) {
left: 0;
top: 0;
}
&:nth-child(2) {
left: 33.333%;
top: 0;
}
&:nth-child(3) {
right: 0;
top: 0;
}
&:nth-child(4) {
left: 0;
top: 33.333%;
}
&:nth-child(5) {
left: 33.333%;
top: 33.333%;
}
&:nth-child(6) {
right: 0;
top: 33.333%;
}
&:nth-child(7) {
bottom: 0;
left: 0;
}
&:nth-child(8) {
bottom: 0;
left: 33.333%;
}
&:nth-child(9) {
bottom: 0;
right: 0;
}
}
}
} }
.heading { &:has(.hover-zone:hover) .card-container {
font-size: 13px; box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3);
scale: 1.05;
&::before {
opacity: 1;
}
} }
.value { &:has(.hover-zone:nth-child(1):hover) {
font-size: 18px; --hover3d-rotate-x: 1;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 0%;
} }
}
&:not(.premium) { &:has(.hover-zone:nth-child(2):hover) {
&:after { --hover3d-rotate-x: 1;
opacity: 0; --hover3d-rotate-y: 0;
--hover3d-shine: 100% 0%;
} }
.card-item { &:has(.hover-zone:nth-child(3):hover) {
background-color: #ffffff; --hover3d-rotate-x: 1;
color: rgba(var(--dark-primary-text)); --hover3d-rotate-y: 1;
--hover3d-shine: 200% 0%;
}
&:has(.hover-zone:nth-child(4):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 100%;
}
&:has(.hover-zone:nth-child(5):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 100%;
}
&:has(.hover-zone:nth-child(6):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 100%;
}
&:has(.hover-zone:nth-child(7):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 200%;
}
&:has(.hover-zone:nth-child(8):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 200%;
}
&:has(.hover-zone:nth-child(9):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 200%;
}
}
}
@media (prefers-reduced-motion: reduce) {
.card-wrapper.hover-3d {
.card-container {
scale: 1 !important;
transform: none !important;
transition: none !important;
&::before {
opacity: 0 !important;
transition: none !important;
}
} }
} }
} }

5
libs/ui/src/lib/membership-card/membership-card.component.stories.ts

@ -26,6 +26,9 @@ export default {
}) })
], ],
argTypes: { argTypes: {
hover3d: {
control: { type: 'boolean' }
},
name: { name: {
control: { type: 'select' }, control: { type: 'select' },
options: ['Basic', 'Premium'] options: ['Basic', 'Premium']
@ -37,6 +40,7 @@ type Story = StoryObj<GfMembershipCardComponent>;
export const Basic: Story = { export const Basic: Story = {
args: { args: {
hover3d: false,
name: 'Basic' name: 'Basic'
} }
}; };
@ -45,6 +49,7 @@ export const Premium: Story = {
args: { args: {
expiresAt: addYears(new Date(), 1).toLocaleDateString(), expiresAt: addYears(new Date(), 1).toLocaleDateString(),
hasPermissionToCreateApiKey: true, hasPermissionToCreateApiKey: true,
hover3d: false,
name: 'Premium' name: 'Premium'
} }
}; };

1
libs/ui/src/lib/membership-card/membership-card.component.ts

@ -34,6 +34,7 @@ import { GfLogoComponent } from '../logo';
export class GfMembershipCardComponent { export class GfMembershipCardComponent {
@Input() public expiresAt: string; @Input() public expiresAt: string;
@Input() public hasPermissionToCreateApiKey: boolean; @Input() public hasPermissionToCreateApiKey: boolean;
@Input() public hover3d = false;
@Input() public name: string; @Input() public name: string;
@Output() generateApiKeyClicked = new EventEmitter<void>(); @Output() generateApiKeyClicked = new EventEmitter<void>();

Loading…
Cancel
Save