diff options
Diffstat (limited to 'src/components/jet/item/ReviewItem.svelte')
| -rw-r--r-- | src/components/jet/item/ReviewItem.svelte | 237 |
1 files changed, 237 insertions, 0 deletions
diff --git a/src/components/jet/item/ReviewItem.svelte b/src/components/jet/item/ReviewItem.svelte new file mode 100644 index 0000000..7f406c8 --- /dev/null +++ b/src/components/jet/item/ReviewItem.svelte @@ -0,0 +1,237 @@ +<script lang="ts"> + import type { Review as ReviewModel } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte'; + import StarRating from '~/components/StarRating.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import { getI18n } from '~/stores/i18n'; + import { getJet } from '~/jet/svelte'; + import { + escapeHtml, + stripUnicodeWhitespace, + } from '~/utils/string-formatting'; + import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics'; + + export let item: ReviewModel; + export let isDetailView: boolean = false; + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const jet = getJet(); + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + + const handleCloseModal = () => modalComponent?.close(); + const handleOpenModal = () => { + modalComponent?.showModal(); + jet.recordCustomMetricsEvent({ + eventType: 'dialog', + dialogId: 'more', + targetId: CUSTOMER_REVIEW_MODAL_ID, + dialogType: 'button', + }); + }; + + $: ({ id, reviewerName, rating, contents, title, date, response } = item); + $: dateForDisplay = jet.localization.timeAgo(new Date(date)); + $: dateForAttribute = new Date(date).toISOString(); + $: titleId = `review-${id}-title`; + $: maximumLinesForReview = response ? 3 : 5; + $: responseDateForDisplay = + response && jet.localization.timeAgo(new Date(response.date)); + $: responseDateForAttribute = + response && new Date(response.date).toISOString(); + $: reviewContents = stripUnicodeWhitespace(escapeHtml(contents)); + $: responseContents = + response && stripUnicodeWhitespace(escapeHtml(response.contents)); +</script> + +<article class:is-detail-view={isDetailView} aria-labelledby={titleId}> + <div class="header"> + <div class="title-and-rating-container"> + {#if !isDetailView} + <h3 id={titleId} class="title"> + <LineClamp clamp={1}> + {title} + </LineClamp> + </h3> + {/if} + + <StarRating + {rating} + --fill-color="var(--systemOrange)" + --star-size={isDetailView ? '24px' : '12px'} + /> + </div> + + <div class="review-header"> + <time class="date" datetime={dateForAttribute}> + {dateForDisplay} + </time> + + <LineClamp clamp={1}> + <p class="author"> + {reviewerName} + </p> + </LineClamp> + </div> + </div> + + {#if isDetailView} + <p> + {@html sanitizeHtml(reviewContents, { + allowedTags: [''], + keepChildrenWhenRemovingParent: true, + })} + + {#if response} + <div class="developer-response-container"> + <div class="developer-response-header"> + <span class="developer-response-heading"> + {$i18n.t( + 'ASE.Web.AppStore.Review.DeveloperResponse', + )} + </span> + + <time class="date" datetime={responseDateForAttribute}> + {responseDateForDisplay} + </time> + </div> + + {@html sanitizeHtml(responseContents, { + allowedTags: [''], + keepChildrenWhenRemovingParent: true, + })} + </div> + {/if} + </p> + {:else} + <div class="content"> + <Truncate + on:openModal={handleOpenModal} + {title} + lines={maximumLinesForReview} + {translateFn} + text={reviewContents} + isPortalModal={true} + /> + + {#if item.response} + <div class="developer-response-container"> + <span class="developer-response-heading"> + {$i18n.t('ASE.Web.AppStore.Review.DeveloperResponse')} + </span> + <Truncate + on:openModal={handleOpenModal} + {title} + {translateFn} + lines={1} + text={responseContents} + isPortalModal={true} + /> + </div> + {/if} + </div> + {/if} +</article> + +{#if !isDetailView} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleCloseModal} + {title} + subtitle={null} + targetId={CUSTOMER_REVIEW_MODAL_ID} + > + <svelte:fragment slot="content"> + <svelte:self {item} isDetailView={true} /> + </svelte:fragment> + </ContentModal> + </Modal> +{/if} + +<style lang="scss"> + article:not(.is-detail-view) { + height: 186px; + padding: 20px 16px; + background-color: var(--systemQuinary); + border-radius: var(--global-border-radius-xlarge); + + @media (--small) { + padding: 20px; + } + } + + .header { + display: flex; + gap: 8px; + margin-bottom: 18px; + align-items: center; + justify-content: space-between; + + .is-detail-view & { + margin-bottom: 0; + } + } + + .title-and-rating-container { + .is-detail-view & { + display: flex; + } + } + + .title { + color: var(--systemPrimary); + font: var(--body-emphasized); + margin-bottom: 4px; + } + + .date, + .author { + color: var(--systemSecondary); + font: var(--callout); + word-break: normal; + } + + .content { + position: relative; + word-wrap: break-word; /* Break to fit the review block, even when people leave a review with long text without spaces */ + text-align: start; + font: var(--body); + } + + .review-header { + text-align: end; + } + + .developer-response-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + margin-top: 20px; + } + + .developer-response-heading { + font: var(--body-emphasized); + + .is-detail-view & { + display: block; + font: var(--title-3-emphasized); + } + } + + .developer-response-container { + margin-top: 10px; + } + + article :global(.more) { + --moreTextColorOverride: var(--keyColor); + --moreFontOverride: var(--body); + text-transform: lowercase; + } +</style> |
