diff --git a/assets/core/scss/components/_tooltip.scss b/assets/core/scss/components/_tooltip.scss index a4d32d0b90..40144c1238 100644 --- a/assets/core/scss/components/_tooltip.scss +++ b/assets/core/scss/components/_tooltip.scss @@ -24,6 +24,11 @@ text-align: center; pointer-events: none; + &-medium { + max-width: 226px; + padding: $tutor-spacing-5; + } + &-large { max-width: 320px; padding: $tutor-spacing-5; diff --git a/assets/core/ts/components/popover.ts b/assets/core/ts/components/popover.ts index caa91ee604..09b201b6fc 100644 --- a/assets/core/ts/components/popover.ts +++ b/assets/core/ts/components/popover.ts @@ -9,7 +9,11 @@ const PLACEMENTS = { BOTTOM_START: 'bottom-start', BOTTOM_END: 'bottom-end', LEFT: 'left', + LEFT_TOP: 'left-top', + LEFT_BOTTOM: 'left-bottom', RIGHT: 'right', + RIGHT_TOP: 'right-top', + RIGHT_BOTTOM: 'right-bottom', } as const; export interface PopoverProps { @@ -82,7 +86,11 @@ export const popover = (props: PopoverProps = {}) => ({ const rtlAdaptations: Record = { [PLACEMENTS.LEFT]: PLACEMENTS.RIGHT, + [PLACEMENTS.LEFT_TOP]: PLACEMENTS.RIGHT_TOP, + [PLACEMENTS.LEFT_BOTTOM]: PLACEMENTS.RIGHT_BOTTOM, [PLACEMENTS.RIGHT]: PLACEMENTS.LEFT, + [PLACEMENTS.RIGHT_TOP]: PLACEMENTS.LEFT_TOP, + [PLACEMENTS.RIGHT_BOTTOM]: PLACEMENTS.LEFT_BOTTOM, [PLACEMENTS.TOP_START]: PLACEMENTS.TOP_END, [PLACEMENTS.TOP_END]: PLACEMENTS.TOP_START, [PLACEMENTS.BOTTOM_START]: PLACEMENTS.BOTTOM_END, @@ -164,7 +172,8 @@ export const popover = (props: PopoverProps = {}) => ({ }; const placement = this.resolvePlacement(this.actualPlacement, triggerRect, contentRect, viewport); - const { top, left } = this.calculatePosition(triggerRect, contentRect, placement); + const viewportPosition = this.calculatePosition(triggerRect, contentRect, placement); + const { top, left } = this.convertViewportPositionToContentPosition(content, viewportPosition); // Apply positioning content.style.position = 'fixed'; @@ -207,12 +216,12 @@ export const popover = (props: PopoverProps = {}) => ({ return placement.replace('bottom', 'top'); } - if (placement === PLACEMENTS.LEFT && needsHorizontalFlip.left) { - return PLACEMENTS.RIGHT; + if (placement.startsWith('left') && needsHorizontalFlip.left) { + return placement.replace('left', 'right'); } - if (placement === PLACEMENTS.RIGHT && needsHorizontalFlip.right) { - return PLACEMENTS.LEFT; + if (placement.startsWith('right') && needsHorizontalFlip.right) { + return placement.replace('right', 'left'); } return placement; @@ -251,15 +260,83 @@ export const popover = (props: PopoverProps = {}) => ({ top = triggerRect.top + (triggerRect.height - contentRect.height) / 2; left = triggerRect.left - contentRect.width - this.offset; break; + case PLACEMENTS.LEFT_TOP: + top = triggerRect.top; + left = triggerRect.left - contentRect.width - this.offset; + break; + case PLACEMENTS.LEFT_BOTTOM: + top = triggerRect.bottom - contentRect.height; + left = triggerRect.left - contentRect.width - this.offset; + break; case PLACEMENTS.RIGHT: top = triggerRect.top + (triggerRect.height - contentRect.height) / 2; left = triggerRect.right + this.offset; break; + case PLACEMENTS.RIGHT_TOP: + top = triggerRect.top; + left = triggerRect.right + this.offset; + break; + case PLACEMENTS.RIGHT_BOTTOM: + top = triggerRect.bottom - contentRect.height; + left = triggerRect.right + this.offset; + break; } return { top, left }; }, + convertViewportPositionToContentPosition(content: HTMLElement, position: { top: number; left: number }) { + const containingBlock = this.getFixedContainingBlock(content); + + if (!containingBlock) { + return position; + } + + const containingBlockRect = containingBlock.getBoundingClientRect(); + const scaleX = containingBlock.offsetWidth ? containingBlockRect.width / containingBlock.offsetWidth || 1 : 1; + const scaleY = containingBlock.offsetHeight ? containingBlockRect.height / containingBlock.offsetHeight || 1 : 1; + + return { + top: (position.top - containingBlockRect.top) / scaleY - containingBlock.clientTop, + left: (position.left - containingBlockRect.left) / scaleX - containingBlock.clientLeft, + }; + }, + + getFixedContainingBlock(element: HTMLElement) { + let parent = element.parentElement; + + while (parent && parent !== document.documentElement) { + if (this.createsFixedContainingBlock(parent)) { + return parent; + } + + parent = parent.parentElement; + } + + return null; + }, + + createsFixedContainingBlock(element: HTMLElement) { + const style = window.getComputedStyle(element); + const willChangeProperties = style.willChange.split(',').map((property) => property.trim()); + const containProperties = style.contain.split(' '); + const backdropFilter = + style.getPropertyValue('backdrop-filter') || style.getPropertyValue('-webkit-backdrop-filter'); + const contentVisibility = style.getPropertyValue('content-visibility'); + const containerType = style.getPropertyValue('container-type'); + + return ( + style.transform !== 'none' || + style.perspective !== 'none' || + style.filter !== 'none' || + (backdropFilter !== '' && backdropFilter !== 'none') || + contentVisibility === 'auto' || + (containerType !== '' && containerType !== 'normal') || + willChangeProperties.some((property) => ['transform', 'perspective', 'filter'].includes(property)) || + containProperties.some((property) => ['layout', 'paint', 'strict', 'content'].includes(property)) + ); + }, + updatePlacementClasses(content: HTMLElement, placement: string) { // Remove all placement classes const placementClasses = ['tutor-popover-top', 'tutor-popover-bottom', 'tutor-popover-left', 'tutor-popover-right']; diff --git a/assets/core/ts/components/tooltip.ts b/assets/core/ts/components/tooltip.ts index a074a6ed86..e632797574 100644 --- a/assets/core/ts/components/tooltip.ts +++ b/assets/core/ts/components/tooltip.ts @@ -18,6 +18,7 @@ const TOOLTIP_TRIGGERS = { const TOOLTIP_SIZES = { SMALL: 'small', + MEDIUM: 'medium', LARGE: 'large', } as const; @@ -328,14 +329,16 @@ export const tooltip = (props: TooltipProps = {}) => { 'tutor-tooltip-start', 'tutor-tooltip-end', ]; - const sizeClasses = ['tutor-tooltip-large']; + const sizeClasses = ['tutor-tooltip-medium', 'tutor-tooltip-large']; const arrowClasses = ['tutor-tooltip-arrow-start', 'tutor-tooltip-arrow-center', 'tutor-tooltip-arrow-end']; content.classList.remove(...placementClasses, ...sizeClasses, ...arrowClasses); content.classList.add(`tutor-tooltip-${placement}`); - if (this.size === TOOLTIP_SIZES.LARGE) { + if (this.size === TOOLTIP_SIZES.MEDIUM) { + content.classList.add('tutor-tooltip-medium'); + } else if (this.size === TOOLTIP_SIZES.LARGE) { content.classList.add('tutor-tooltip-large'); } diff --git a/assets/icons/camera.svg b/assets/icons/camera.svg new file mode 100644 index 0000000000..c63cf08286 --- /dev/null +++ b/assets/icons/camera.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/src/js/frontend/dashboard/pages/settings.ts b/assets/src/js/frontend/dashboard/pages/settings.ts index a317fc66b8..e22143ef67 100644 --- a/assets/src/js/frontend/dashboard/pages/settings.ts +++ b/assets/src/js/frontend/dashboard/pages/settings.ts @@ -7,9 +7,13 @@ import endpoints from '@TutorShared/utils/endpoints'; import { type TutorMutationResponse } from '@TutorShared/utils/types'; import { convertToErrorMessage } from '@TutorShared/utils/util'; -interface ProfilePhotoFormProps { +interface UserPhotoFormProps { photo_file: File; - photo_type: 'profile_photo'; + photo_type: 'profile_photo' | 'cover_photo'; +} + +interface RemoveUserPhotoProps { + photo_type: UserPhotoFormProps['photo_type']; } interface AccountFormProps { @@ -21,6 +25,8 @@ interface AccountFormProps { occupation: string; bio: string; display_name: string; + profile_photo: string; + cover_photo: string; tutor_pro_custom_signature_id: WPMedia | null; } @@ -89,6 +95,8 @@ const settings = () => { $el: null as HTMLElement | null, uploadProfilePhotoMutation: null as MutationState> | null, removeProfilePhotoMutation: null as MutationState> | null, + uploadCoverPhotoMutation: null as MutationState> | null, + removeCoverPhotoMutation: null as MutationState> | null, updateProfileMutation: null as MutationState> | null, saveSocialProfileMutation: null as MutationState> | null, saveBillingInfoMutation: null as MutationState> | null, @@ -106,6 +114,8 @@ const settings = () => { this.handleUpdateProfile = this.handleUpdateProfile.bind(this); this.handleUploadProfilePhoto = this.handleUploadProfilePhoto.bind(this); this.handleRemoveProfilePhoto = this.handleRemoveProfilePhoto.bind(this); + this.handleUploadCoverPhoto = this.handleUploadCoverPhoto.bind(this); + this.handleRemoveCoverPhoto = this.handleRemoveCoverPhoto.bind(this); this.handleSaveSocialProfile = this.handleSaveSocialProfile.bind(this); this.handleSaveBillingInfo = this.handleSaveBillingInfo.bind(this); this.handleSaveWithdrawMethod = this.handleSaveWithdrawMethod.bind(this); @@ -122,7 +132,7 @@ const settings = () => { }, }); - this.uploadProfilePhotoMutation = query.useMutation(this.uploadProfilePhoto, { + this.uploadProfilePhotoMutation = query.useMutation(this.uploadUserPhoto, { onSuccess: () => { toast.success(__('Successfully updated profile photo.', 'tutor')); }, @@ -131,7 +141,7 @@ const settings = () => { }, }); - this.removeProfilePhotoMutation = query.useMutation(this.removeProfilePhoto, { + this.removeProfilePhotoMutation = query.useMutation(this.removeUserPhoto, { onSuccess: () => { toast.success(__('Successfully removed profile photo.', 'tutor')); }, @@ -140,6 +150,24 @@ const settings = () => { }, }); + this.uploadCoverPhotoMutation = query.useMutation(this.uploadUserPhoto, { + onSuccess: () => { + toast.success(__('Successfully updated cover photo.', 'tutor')); + }, + onError: (error: Error) => { + toast.error(convertToErrorMessage(error)); + }, + }); + + this.removeCoverPhotoMutation = query.useMutation(this.removeUserPhoto, { + onSuccess: () => { + toast.success(__('Successfully removed cover photo.', 'tutor')); + }, + onError: (error: Error) => { + toast.error(convertToErrorMessage(error)); + }, + }); + this.updateProfileMutation = query.useMutation(this.updateProfile, { onSuccess: (data: TutorMutationResponse) => { toast.success(data?.message ?? __('Successfully updated profile', 'tutor')); @@ -271,7 +299,7 @@ const settings = () => { return wpAjaxInstance.post(endpoints.UPDATE_PROFILE_NOTIFICATION, transformedPayload).then((res) => res.data); }, - async uploadProfilePhoto(payload: ProfilePhotoFormProps) { + async uploadUserPhoto(payload: UserPhotoFormProps) { return wpAjaxInstance.post(endpoints.UPLOAD_PROFILE_PHOTO, payload).then((res) => res.data); }, @@ -282,16 +310,31 @@ const settings = () => { const data = { photo_file: files[0], photo_type: 'profile_photo', - } satisfies ProfilePhotoFormProps; + } satisfies UserPhotoFormProps; await this.uploadProfilePhotoMutation?.mutate(data); }, - async removeProfilePhoto() { - return wpAjaxInstance.post(endpoints.REMOVE_PROFILE_PHOTO).then((res) => res.data); + async handleUploadCoverPhoto(files: File[]) { + if (files.length === 0) { + return; + } + const data = { + photo_file: files[0], + photo_type: 'cover_photo', + } satisfies UserPhotoFormProps; + await this.uploadCoverPhotoMutation?.mutate(data); + }, + + async removeUserPhoto(payload: RemoveUserPhotoProps) { + return wpAjaxInstance.post(endpoints.REMOVE_PROFILE_PHOTO, payload).then((res) => res.data); }, async handleRemoveProfilePhoto() { - await this.removeProfilePhotoMutation?.mutate({}); + await this.removeProfilePhotoMutation?.mutate({ photo_type: 'profile_photo' }); + }, + + async handleRemoveCoverPhoto() { + await this.removeCoverPhotoMutation?.mutate({ photo_type: 'cover_photo' }); }, async updateProfile(payload: AccountFormProps) { diff --git a/assets/src/js/v3/shared/icons/types.ts b/assets/src/js/v3/shared/icons/types.ts index 0fc0ac5d20..a54bdbca3c 100644 --- a/assets/src/js/v3/shared/icons/types.ts +++ b/assets/src/js/v3/shared/icons/types.ts @@ -59,6 +59,7 @@ export const icons = [ 'calendarLine', 'calendarLines', 'callEnd', + 'camera', 'cart', 'categories', 'certificate', diff --git a/assets/src/scss/frontend/dashboard/settings/_account.scss b/assets/src/scss/frontend/dashboard/settings/_account.scss index 9646177b1d..eb48cf7b3d 100644 --- a/assets/src/scss/frontend/dashboard/settings/_account.scss +++ b/assets/src/scss/frontend/dashboard/settings/_account.scss @@ -2,18 +2,56 @@ @use '@Core/scss/tokens' as *; .tutor-account-section { + .tutor-account-cover-photo { + min-height: 184px; + border-radius: $tutor-radius-lg; + background-color: $tutor-surface-brand-quaternary; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + position: relative; + + &.is-loading { + pointer-events: none; + background-image: none !important; + + & > * { + visibility: hidden; + } + + @include tutor-loading-spinner($tutor-surface-l1, $tutor-surface-l1-hover, 44px); + + &::before { + z-index: $tutor-z-positive; + } + } + + .tutor-account-cover-photo-action { + @include tutor-flex(); + gap: $tutor-spacing-3; + position: absolute; + top: $tutor-spacing-4; + right: $tutor-spacing-4; + } + } + .tutor-account-avatar-wrapper { @include tutor-flex(row, center, center); - background-color: $tutor-surface-brand-tertiary; - border-radius: $tutor-radius-lg; - padding: $tutor-spacing-10; + transform: translateY(-52px); + margin-bottom: -52px; + position: relative; + z-index: $tutor-z-positive; .tutor-account-avatar { @include tutor-avatar-base(); width: 104px; height: 104px; - border: 2px solid $tutor-border-brand-secondary; - position: relative; + border: 4px solid $tutor-border-inverse; + overflow: visible; + + img { + border-radius: $tutor-radius-full; + } &.is-loading { position: relative; @@ -28,18 +66,17 @@ } .tutor-account-avatar-edit { - @include tutor-button-reset(); position: absolute; - inset: 0; - background-color: rgba($color: #000000, $alpha: 0.5); - color: $tutor-icon-idle-inverse; - display: none; - } + right: 0; + bottom: 0; + border: 3px solid $tutor-border-inverse; + border-radius: $tutor-radius-full; + @include tutor-transition((background-color, color)); - &.active, - &:hover { - .tutor-account-avatar-edit { - display: block; + &:hover, + &:focus, + &:active { + border: 3px solid $tutor-border-inverse; } } } diff --git a/classes/Icon.php b/classes/Icon.php index e254564307..3abeb94b37 100644 --- a/classes/Icon.php +++ b/classes/Icon.php @@ -75,6 +75,7 @@ final class Icon { const CALENDAR_LINE = 'calendar-line'; const CALENDAR_LINES = 'calendar-lines'; const CALL_END = 'call-end'; + const CAMERA = 'camera'; const CART = 'cart'; const CATEGORIES = 'categories'; const CERTIFICATE = 'certificate'; diff --git a/components/Constants/Positions.php b/components/Constants/Positions.php index 5f66898086..b645a5dbae 100644 --- a/components/Constants/Positions.php +++ b/components/Constants/Positions.php @@ -25,7 +25,11 @@ abstract class Positions { * @since 4.0.0 */ public const LEFT = 'left'; + public const LEFT_TOP = 'left-top'; + public const LEFT_BOTTOM = 'left-bottom'; public const RIGHT = 'right'; + public const RIGHT_TOP = 'right-top'; + public const RIGHT_BOTTOM = 'right-bottom'; public const TOP = 'top'; public const TOP_START = 'top-start'; public const TOP_END = 'top-end'; diff --git a/components/Popover.php b/components/Popover.php index 88725b77a4..ded0c71144 100644 --- a/components/Popover.php +++ b/components/Popover.php @@ -56,7 +56,11 @@ class Popover extends BaseComponent { Positions::BOTTOM_START => 'left.top', Positions::BOTTOM_END => 'right.top', Positions::LEFT => 'right.center', + Positions::LEFT_TOP => 'right.top', + Positions::LEFT_BOTTOM => 'right.bottom', Positions::RIGHT => 'left.center', + Positions::RIGHT_TOP => 'left.top', + Positions::RIGHT_BOTTOM => 'left.bottom', ); /** @@ -219,9 +223,23 @@ public function body( string $popover_body, array $allowed_html_tags = array() ) * @return self */ public function placement( string $popover_placement = 'bottom-start' ): self { - $placement_positions = array( Positions::TOP, Positions::LEFT, Positions::RIGHT, Positions::BOTTOM, Positions::BOTTOM_START, Positions::BOTTOM_END ); + $placement_positions = array( + Positions::TOP, + Positions::TOP_START, + Positions::TOP_END, + Positions::LEFT, + Positions::LEFT_TOP, + Positions::LEFT_BOTTOM, + Positions::RIGHT, + Positions::RIGHT_TOP, + Positions::RIGHT_BOTTOM, + Positions::BOTTOM, + Positions::BOTTOM_START, + Positions::BOTTOM_END, + ); if ( ! in_array( $popover_placement, $placement_positions, true ) ) { $this->popover_placement = Positions::BOTTOM_START; + return $this; } $this->popover_placement = $popover_placement; @@ -524,10 +542,10 @@ public function get(): string { $footer = $this->render_footer(); $menu = $this->render_menu(); - $placement_class = Positions::BOTTOM_START !== $placement_position ? "tutor-popover-$placement_position" : 'tutor-popover-top'; + $placement_class = 'tutor-popover-' . explode( '-', $placement_position )[0]; $class = 'tutor-popover ' . $placement_class; - $closeable_attr = $this->popover_close_outside ? '@click.outside="handleClickOutside()' : ''; + $closeable_attr = $this->popover_close_outside ? '@click.outside="handleClickOutside()"' : ''; $origin = self::TRANSFORM_ORIGIN_MAP[ $placement_position ] ?? 'center.top'; @@ -539,8 +557,8 @@ public function get(): string { x-show="open" x-cloak x-transition.%s - class=%s - %s" + class="%s" + %s > %s %s diff --git a/components/Tooltip.php b/components/Tooltip.php index 22b8e4737f..7275a00cfd 100644 --- a/components/Tooltip.php +++ b/components/Tooltip.php @@ -71,7 +71,7 @@ class Tooltip extends BaseComponent { protected $placement = self::PLACEMENT_TOP; /** - * Tooltip size (small|large). + * Tooltip size (small|medium|large). * * @var string */ @@ -158,7 +158,7 @@ public function placement( string $placement ): self { * @return $this */ public function size( string $size ): self { - $allowed = array( Size::SMALL, Size::LARGE ); + $allowed = array( Size::SMALL, Size::MEDIUM, Size::LARGE ); if ( in_array( $size, $allowed, true ) ) { $this->size = $size; } diff --git a/templates/dashboard/account/settings/account.php b/templates/dashboard/account/settings/account.php index 3186fa115e..d62e07b88c 100644 --- a/templates/dashboard/account/settings/account.php +++ b/templates/dashboard/account/settings/account.php @@ -17,6 +17,7 @@ use Tutor\Components\Constants\Size; use Tutor\Components\Constants\Variant; use Tutor\Components\Constants\InputType; +use Tutor\Components\Tooltip; use Tutor\Components\WPEditor; $user = wp_get_current_user(); @@ -72,86 +73,180 @@ class="tutor-flex tutor-flex-column tutor-gap-6"
- +
+