diff --git a/README.md b/README.md index 3b5ee0b..6a31319 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,16 @@ Visit the [interactive playground](https://playcode.io/capture_eye_demo) for the ## Component attributes -| Attribute Name | Required | Description | Example | -| -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `nid` | Yes | The unique [Nid](https://docs.numbersprotocol.io/introduction/numbers-protocol/defining-web3-assets/numbers-id-nid) of the asset. | `` | -| `layout` | No | Decides which layout to display. Default value is `original`. Additional option includes `curated`. | `` | -| `visibility` | No | Visibility behavior. Default value is `hover`, showing Eye on mouse hover. Setting it to option `always` will make the Eye always shown. | `` | -| `cz-title` | No | Override the copyright zone title. | `` | -| `eng-img` | No | Override the engagement zone banner image. Recommended size is 400x200 px but any image with width >= 320px should work. | `` | -| `eng-link` | No | Override the engagement zone banner link. | `` | -| `action-button-text` | No | Override the default action button text (`View More`). | `` | -| `action-button-link` | No | Override the default action button link to Capture website. | `` | +| Attribute Name | Required | Description | +| ---- | --- | --- | +| `nid` | Yes | The unique [Nid](https://docs.numbersprotocol.io/introduction/numbers-protocol/defining-web3-assets/numbers-id-nid) of the asset.
`` | +| `layout` | No | Decides which layout to display. Default value is `original`. Additional option includes `curated`.
`` | +| `visibility` | No | Visibility behavior. Default value is `hover`, showing Eye on mouse hover. Setting it to option `always` will make the Eye always shown. This attribute is `always` and cannot be customized on mobile devices.
`` | +| `cz-title` | No | Override the copyright zone title.
`` | +| eng-img | No | Sets the image for the engagement zone banner. Recommended dimensions: `320x120 px`. Supports multiple `eng-img` and `eng-link` pairs for rotating banners. If multiple pairs are provided by using comma to seperate each URL entry, the banner will rotate every 5 seconds. Ensure that the number of `eng-img` matches the number of `eng-link` entries. Use URL encoding to replace commas with `%2C` in the URLs.
`` | +| eng-link | No | Sets the URL for the engagement zone banner link. Each `eng-link` should correspond to an `eng-img` for proper pairing in rotating banners, following the same comma-separated rule.
`` | +| `action-button-text` | No | Override the default action button text (`View More`).
`` | +| `action-button-link` | No | Override the default action button link to Capture website.
`` | ## Integration with Frontend Frameworks diff --git a/dev/index.html b/dev/index.html index 3cded21..2cfd169 100644 --- a/dev/index.html +++ b/dev/index.html @@ -61,8 +61,8 @@ diff --git a/scripts/deploy-icons.sh b/scripts/deploy-icons.sh index f434413..88bbc53 100755 --- a/scripts/deploy-icons.sh +++ b/scripts/deploy-icons.sh @@ -5,34 +5,59 @@ DISTRIBUTION_ID="E1GEDS049Y53CA" S3_BUCKET="numbers-static" S3_PATH="capture-eye" ICON_DIR="assets/icons" -INVALIDATION_PATHS=( - "/${S3_PATH}/capture-eye-blockchain-icon.svg" - "/${S3_PATH}/capture-eye-curator-icon.png" - "/${S3_PATH}/capture-eye-close-icon.png" - "/${S3_PATH}/capture-eye-tx-icon.svg" - "/${S3_PATH}/capture-eye-gray.svg" - "/${S3_PATH}/capture-eye-close-gray.svg" -) -FILES_TO_UPLOAD=( - "${ICON_DIR}/capture-eye-blockchain-icon.svg" - "${ICON_DIR}/capture-eye-curator-icon.png" - "${ICON_DIR}/capture-eye-close-icon.png" - "${ICON_DIR}/capture-eye-tx-icon.svg" - "${ICON_DIR}/capture-eye-gray.svg" - "${ICON_DIR}/capture-eye-close-gray.svg" -) - -# Upload files to S3 -for file in "${FILES_TO_UPLOAD[@]}"; do - aws s3 cp $file s3://$S3_BUCKET/$S3_PATH/ - if [ $? -ne 0 ]; then - echo "Failed to upload $file to s3://$S3_BUCKET/$S3_PATH/" - exit 1 + +# Find all files in the ICON_DIR recursively +FILES_TO_UPLOAD=$(find $ICON_DIR -type f) + +# Function to get the local MD5 checksum +get_local_md5() { + file=$1 + md5sum "$file" | awk '{ print $1 }' +} + +# Function to get the S3 MD5 checksum (ETag) +get_s3_md5() { + file=$1 + s3_key="$S3_PATH/$(basename "$file")" + aws s3api head-object --bucket "$S3_BUCKET" --key "$s3_key" --query ETag --output text 2>/dev/null | tr -d '"' +} + +# Print all files that will be checked for upload +echo "Checking files for changes:" +for file in $FILES_TO_UPLOAD; do + local_md5=$(get_local_md5 "$file") + s3_md5=$(get_s3_md5 "$file") + + if [ "$local_md5" != "$s3_md5" ]; then + echo "Uploading new or modified file: $file" + + # Upload the file to S3 + aws s3 cp "$file" "s3://$S3_BUCKET/$S3_PATH/$(basename "$file")" + if [ $? -ne 0 ]; then + echo "Failed to upload $file to s3://$S3_BUCKET/$S3_PATH/$(basename "$file")" + exit 1 + fi + echo "File uploaded to s3://$S3_BUCKET/$S3_PATH/$(basename "$file")" + + # Add to invalidation paths + INVALIDATION_PATHS+=("/$S3_PATH/$(basename "$file")") + else + echo "File unchanged, skipping upload: $file" fi - echo "File uploaded to s3://$S3_BUCKET/$S3_PATH/" done -# Create the invalidation +# Check if there are any files to invalidate +if [ ${#INVALIDATION_PATHS[@]} -eq 0 ]; then + echo "No files to invalidate." + exit 0 +fi + +# Create the invalidation with all uploaded files +echo "Creating invalidation for the following paths:" +for path in "${INVALIDATION_PATHS[@]}"; do + echo "$path" +done + INVALIDATION_ID=$(aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "${INVALIDATION_PATHS[@]}" --query 'Invalidation.Id' --output text) if [ $? -ne 0 ]; then echo "Failed to create invalidation" @@ -50,9 +75,16 @@ check_invalidation_status() { while true; do check_invalidation_status if [ "$STATUS" = "Completed" ]; then - echo "success/done" + echo "Invalidation completed successfully." break fi # Wait for 5 seconds before checking again sleep 5 done + +# Print the CDN URLs of the uploaded files +echo "CDN URLs for uploaded files:" +for path in "${INVALIDATION_PATHS[@]}"; do + filename=$(basename "$path") + echo "https://static-cdn.numbersprotocol.io/$S3_PATH/$filename" +done diff --git a/src/capture-eye.ts b/src/capture-eye.ts index 5d89ceb..10882de 100644 --- a/src/capture-eye.ts +++ b/src/capture-eye.ts @@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js'; import { Constant } from './constant.js'; import { getCaptureEyeStyles } from './capture-eye-styles.js'; import { ModalManager } from './modal/modal-manager.js'; -import { CaptureEyeModal } from './modal/modal.js'; +import { CaptureEyeModal, EngagementZone } from './modal/modal.js'; import { MediaViewer } from './media-viewer/media-viewer.js'; import interactionTracker, { TrackerEvent, @@ -39,13 +39,13 @@ export class CaptureEye extends LitElement { copyrightZoneTitle = ''; /** - * Url of the engagement image. + * Urls of the engagement images. Use comma to separate multiple images. */ @property({ type: String, attribute: 'eng-img' }) engagementImage = ''; /** - * Url of the engagement link. + * Urls of the engagement links. Use comma to separate multiple links. */ @property({ type: String, attribute: 'eng-link' }) engagementLink = ''; @@ -141,8 +141,7 @@ export class CaptureEye extends LitElement { nid: this.nid, layout: this.layout, copyrightZoneTitle: this.copyrightZoneTitle, - engagementImage: this.engagementImage, - engagementLink: this.engagementLink, + engagementZones: this.engagementZones, actionButtonText: this.actionButtonText, actionButtonLink: this.actionButtonLink, position: { @@ -194,6 +193,26 @@ export class CaptureEye extends LitElement { font.rel = 'stylesheet'; document.head.appendChild(font); } + + private get engagementZones() { + const engagementImages = this.engagementImage + .split(',') + .map((url) => url.trim()) + .filter((url) => url !== ''); + const engagementLinks = this.engagementLink + .split(',') + .map((url) => url.trim()) + .filter((url) => url !== ''); + /* Use image as base. For unexpected use cases links > images, ignore the exceeding links. + * For unexpected use cases images > links, use default link. + */ + return engagementImages.map((image, index) => { + return { + image, + link: engagementLinks[index] || Constant.url.defaultEngagementLink, + } as EngagementZone; + }); + } } declare global { diff --git a/src/modal/modal-styles.ts b/src/modal/modal-styles.ts index 0503a78..17e8c5d 100644 --- a/src/modal/modal-styles.ts +++ b/src/modal/modal-styles.ts @@ -216,11 +216,47 @@ export function getModalStyles() { color: var(--secondary-text-color); } + .slideshow-container { + position: relative; + width: 320px; + height: 120px; + } .eng-img { - width: 100%; + width: 320px; + height: 120px; display: block; + object-fit: contain; border-bottom-left-radius: var(--border-radius); border-bottom-right-radius: var(--border-radius); + background-color: #eee; + } + .prev, + .next { + position: absolute; + top: 0; + bottom: 0; + width: 30px; /* Make the buttons thinner */ + background-color: rgba(0, 0, 0, 0); /* Semi-transparent background */ + color: white; + border: none; + padding: 0; + cursor: pointer; + font-size: 18px; + user-select: none; + opacity: 0; + } + .prev { + left: 0; + border-bottom-left-radius: var(--border-radius); + } + .next { + right: 0; + border-bottom-right-radius: var(--border-radius); + } + .prev:hover, + .next:hover { + background-color: rgba(0, 0, 0, 0.3); /* Darker background on hover */ + opacity: 1; } /* Shimmer effect */ diff --git a/src/modal/modal.ts b/src/modal/modal.ts index 8c1d740..9310811 100644 --- a/src/modal/modal.ts +++ b/src/modal/modal.ts @@ -15,12 +15,16 @@ export function formatTxHash(txHash: string): string { return `${firstPart}...${lastPart}`; } +export interface EngagementZone { + image: string; + link: string; +} + export interface ModalOptions { nid: string; layout?: string; copyrightZoneTitle?: string; - engagementImage?: string; - engagementLink?: string; + engagementZones?: EngagementZone[]; actionButtonText?: string; actionButtonLink?: string; position?: { top: number; left: number }; @@ -30,18 +34,16 @@ export interface ModalOptions { export class CaptureEyeModal extends LitElement { static override styles = getModalStyles(); - @property({ type: String }) - nid = ''; - - @property({ type: String }) - layout = Constant.layout.original; - - @property({ type: Boolean }) - modalHidden = true; + @property({ type: String }) nid = ''; + @property({ type: String }) layout = Constant.layout.original; + @property({ type: Boolean }) modalHidden = true; @state() protected _copyrightZoneTitle = ''; - @state() protected _engagementImage = ''; - @state() protected _engagementLink = ''; + @state() protected _engagementZones: EngagementZone[] = []; + @state() protected _engagementZoneIndex = 0; + @state() protected _engagementZoneRotationInterval = 5000; + @state() protected _engagementZoneRotationIntervalId: number | undefined = + undefined; @state() protected _actionButtonText = ''; @state() protected _actionButtonLink = ''; @state() protected _asset: AssetModel | undefined = undefined; @@ -54,6 +56,21 @@ export class CaptureEyeModal extends LitElement { super(); } + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.stopEngagementZoneRotation(); + } + + override firstUpdated() { + this.updateModalVisibility(); + } + + override updated(changedProperties: Map) { + if (changedProperties.has('modalHidden')) { + this.updateModalVisibility(); + } + } + updateAsset(asset: AssetModel, setAsLoaded = false) { this._asset = { ...this._asset, ...asset }; if (setAsLoaded) this._assetLoaded = true; @@ -64,11 +81,15 @@ export class CaptureEyeModal extends LitElement { if (options.layout) this.layout = options.layout; if (options.copyrightZoneTitle) this._copyrightZoneTitle = options.copyrightZoneTitle; - if (options.engagementImage !== this._engagementImage) { + if ( + options.engagementZones !== undefined && + JSON.stringify(options.engagementZones) !== + JSON.stringify(this._engagementZones) + ) { this._imageLoaded = false; - this._engagementImage = options.engagementImage ?? ''; + this._engagementZones = options.engagementZones; } - if (options.engagementLink) this._engagementLink = options.engagementLink; + this.preloadEngagementZoneImages(); if (options.actionButtonText) this._actionButtonText = options.actionButtonText; if (options.actionButtonLink) @@ -83,27 +104,19 @@ export class CaptureEyeModal extends LitElement { } clearModalOptions() { + this.stopEngagementZoneRotation(); this.nid = ''; this.layout = Constant.layout.original; this._copyrightZoneTitle = ''; - this._engagementImage = ''; - this._engagementLink = ''; + this._engagementZones = []; + this._engagementZoneIndex = 0; + this._engagementZoneRotationIntervalId = undefined; this._actionButtonText = ''; this._actionButtonLink = ''; this._asset = undefined; this._assetLoaded = false; } - override firstUpdated() { - this.updateModalVisibility(); - } - - override updated(changedProperties: Map) { - if (changedProperties.has('modalHidden')) { - this.updateModalVisibility(); - } - } - private updateModalVisibility() { const closeButton = this.shadowRoot?.querySelector('.close-button'); if (this.modalElement && closeButton) { @@ -141,6 +154,62 @@ export class CaptureEyeModal extends LitElement { ); } + private startEngagementZoneRotation() { + if (this._engagementZones.length <= 1) return; + this._engagementZoneRotationIntervalId = setInterval(() => { + this._engagementZoneIndex = + (this._engagementZoneIndex + 1) % this._engagementZones.length; + }, this._engagementZoneRotationInterval); + } + + private stopEngagementZoneRotation() { + if (this._engagementZoneRotationIntervalId) + clearInterval(this._engagementZoneRotationIntervalId); + } + + private rotateNext() { + if (this._engagementZones.length <= 1) return; + this._engagementZoneIndex = + (this._engagementZoneIndex + 1) % this._engagementZones.length; + } + private rotatePrev() { + if (this._engagementZones.length <= 1) return; + this._engagementZoneIndex = + (this._engagementZoneIndex - 1 + this._engagementZones.length) % + this._engagementZones.length; + } + + private preloadEngagementZoneImages(): Promise { + return new Promise((resolve, reject) => { + let loadedImages = 0; + const imageUrls = + this._engagementZones.length > 0 + ? this._engagementZones.map((zone) => zone.image) + : [Constant.url.defaultEngagementImage]; + + imageUrls.forEach((url) => { + const img = new Image(); + img.src = url; + img.onload = () => { + loadedImages++; + if (loadedImages === imageUrls.length) { + this.handleImageLoad(); + resolve(); + } + }; + img.onerror = () => { + console.error(`Image failed to load: ${url}`); + reject(new Error(`Image failed to load: ${url}`)); + }; + }); + }); + } + + private handleImageLoad() { + this._imageLoaded = true; + this.startEngagementZoneRotation(); + } + private isOriginal() { return this.layout == Constant.layout.original; } @@ -335,9 +404,44 @@ export class CaptureEyeModal extends LitElement { `; } - private handleImageLoad() { - this._imageLoaded = true; - console.debug('Engagement image loaded'); + private get currentEngagementZone() { + const defaultEngagementZone: EngagementZone = { + image: Constant.url.defaultEngagementImage, + link: Constant.url.defaultEngagementLink, + }; + const currentEngagementZone = + this._engagementZones.length > 0 + ? this._engagementZones[this._engagementZoneIndex] + : defaultEngagementZone; + return currentEngagementZone; + } + + private renderEngagementZone() { + return html` +
+ + Slideshow Image + ${!this._imageLoaded ? html`
` : ''} +
+ + ${this._engagementZones.length > 1 + ? html` + ` + : ''} +
+ `; } override render() { @@ -357,24 +461,7 @@ export class CaptureEyeModal extends LitElement { ${this.renderBottom()} - - Full width - ${!this._imageLoaded - ? html`
` - : ''} -
+ ${this.renderEngagementZone()}
{
- - + -
-
-
+ +
+
+ +
Close
@@ -115,29 +117,34 @@ suite('capture-eye-modal', () => { }); test('renders engagement image and link correctly', async () => { - const engagementImage = 'https://example.com/image.jpg'; + const engagementImage = + 'https://static-cdn.numbersprotocol.io/capture-eye/capture-ad.png'; const engagementLink = 'https://example.com'; + + // Fixture to create the CaptureEyeModal component const el = await fixture(html` `); + // Set the modal options with engagement zones el.updateModalOptions({ nid: '123', - engagementImage, - engagementLink, + engagementZones: [{ image: engagementImage, link: engagementLink }], }); await el.updateComplete; + const img = el.shadowRoot?.querySelector('.eng-img') as HTMLImageElement; expect(img).to.exist; - expect(img!.src).to.equal(engagementImage); - img.dispatchEvent(new Event('load')); + expect(new URL(img.src).href).to.equal(new URL(engagementImage).href); + + await (el as any).preloadEngagementZoneImages(); await el.updateComplete; expect(img.style.display).to.equal('block'); const link = el.shadowRoot?.querySelector('.eng-link') as HTMLAnchorElement; expect(link).to.exist; - expect(new URL(link!.href).href).to.equal(new URL(engagementLink).href); + expect(new URL(link.href).href).to.equal(new URL(engagementLink).href); }); test('should render custom provenance zone correctly', async () => { @@ -233,8 +240,13 @@ suite('capture-eye-modal', () => { el.updateModalOptions({ nid: '123', layout: 'custom-layout', - engagementImage: 'https://example.com/image.jpg', - engagementLink: 'https://example.com', + engagementZones: [ + { + image: + 'https://static-cdn.numbersprotocol.io/capture-eye/capture-ad.png', + link: 'https://example.com', + }, + ], }); await el.updateComplete; @@ -246,8 +258,7 @@ suite('capture-eye-modal', () => { // Verify that options are reset to defaults expect(el.nid).to.equal(''); expect(el.layout).to.equal(Constant.layout.original); - expect((el as any)._engagementImage).to.equal(''); - expect((el as any)._engagementLink).to.equal(''); + expect((el as any)._engagementZones).to.deep.equal([]); }); test('modal visibility toggle works with transition end', async () => { @@ -291,7 +302,13 @@ suite('capture-eye-modal', () => { el.updateModalOptions({ nid: '123', - engagementLink, + engagementZones: [ + { + image: + 'https://static-cdn.numbersprotocol.io/capture-eye/capture-ad.png', + link: engagementLink, + }, + ], }); await el.updateComplete;