PageSourceSearch

https://commerce-assets.prodpi.com/assets/controllers/project/present/present_rating_controller-E8o5hAd.js

js prodpi.com collected 2026-10-03 22:31:26 UTC 6,010 bytes, 209 lines download raw bytes

1import { Controller } from '@hotwired/stimulus'
2import { PhotoRatingService } from '../../../services/Project/Gallery/PhotoRatingService.js'
3import {
4  RATING_MAYBE,
5  RATING_NO,
6  RATING_UNRATED,
7  RATING_YES,
8  isValidRating,
9  normalizeRating,
10  resolveRatingArrowDown,
11  resolveRatingArrowUp,
12  resolveRatingClick,
13} from '../../../utils/photoRating.js'
14import { updatePresentStripThumbRating } from '../../../utils/presentStrip.js'
15
16/* stimulusFetch: 'lazy' */
17export default class extends Controller {
18  static targets = ['yesButton', 'maybeButton', 'noButton', 'error']
19
20  static RATING_ERROR_MESSAGE = "Couldn't save rating. Please try again."
21
22  static values = {
23    projectId: String,
24  }
25
26  #ratingRequestId = 0
27
28  connect() {
29    this.ratingService = new PhotoRatingService()
30    this.activePhotoId = null
31    this.activeRating = RATING_UNRATED
32    this.#ratingRequestId = 0
33    this.#setButtonsDisabled(true)
34
35    requestAnimationFrame(() => this.#syncFromStripSelection())
36  }
37
38  onPhotoSelected(event) {
39    // Changing photos invalidates any in-flight rating save, so a late failure
40    // for the previous photo can't fire its error/rollback over this one.
41    this.#ratingRequestId += 1
42    const photoId = this.#normalizePhotoId(event.detail?.photoId)
43    this.activePhotoId = photoId || null
44    this.activeRating = normalizeRating(event.detail?.rating)
45    this.#setButtonsDisabled(!this.activePhotoId)
46    this.#updateActiveButtons()
47    this.#clearError()
48  }
49
50  onRatingClick(event) {
51    const button = event.target.closest('[data-rating]')
52    if (!button || !this.activePhotoId) return
53
54    const clicked = Number.parseInt(button.dataset.rating, 10)
55    const nextRating = resolveRatingClick(this.activeRating, clicked)
56    this.#applyRating(nextRating)
57  }
58
59  onRatingKeydown(event) {
60    if (!this.#shouldHandleKey(event) || !this.activePhotoId) return
61
62    let nextRating = null
63
64    if (event.key === 'ArrowUp') {
65      event.preventDefault()
66      nextRating = resolveRatingArrowUp(this.activeRating)
67    } else if (event.key === 'ArrowDown') {
68      event.preventDefault()
69      nextRating = resolveRatingArrowDown(this.activeRating)
70    } else {
71      const keyRatings = {
72        0: 0,
73        1: 1,
74        2: 2,
75        3: 3,
76      }
77      if (!(event.key in keyRatings)) return
78      event.preventDefault()
79      nextRating = keyRatings[event.key]
80    }
81
82    this.#applyRating(nextRating)
83  }
84
85  #shouldHandleKey(event) {
86    const tag = event.target?.tagName?.toLowerCase()
87    if (tag === 'input' || tag === 'textarea' || tag === 'select') return false
88    if (event.target?.isContentEditable) return false
89    return true
90  }
91
92  async #applyRating(rating) {
93    const nextRating = normalizeRating(rating)
94    if (
95      !this.projectIdValue ||
96      !this.activePhotoId ||
97      !isValidRating(nextRating)
98    ) {
99      return
100    }
101
102    const photoId = this.activePhotoId
103    const previous = normalizeRating(this.activeRating)
104    const reviewRoot = this.element.closest('.hd-PresentReview')
105    const requestId = ++this.#ratingRequestId
106
107    this.activeRating = nextRating
108    this.#updateActiveButtons()
109    this.#setButtonsDisabled(true)
110    this.#clearError()
111    updatePresentStripThumbRating(reviewRoot, photoId, nextRating)
112
113    try {
114      await this.ratingService.updateRating(
115        this.projectIdValue,
116        photoId,
117        nextRating
118      )
119      if (requestId !== this.#ratingRequestId) return
120      this.#emitRated(photoId, nextRating)
121    } catch (error) {
122      if (requestId !== this.#ratingRequestId) return
123      // Roll the optimistic UI back to the last persisted rating and tell the
124      // reviewer, so a failed save isn't mistaken for a recorded one.
125      this.activeRating = previous
126      this.#updateActiveButtons()
127      updatePresentStripThumbRating(reviewRoot, photoId, previous)
128      this.#showError()
129      console.warn('Failed to update photo rating', error)
130    } finally {
131      if (requestId === this.#ratingRequestId) {
132        this.#setButtonsDisabled(!this.activePhotoId)
133      }
134    }
135  }
136
137  #showError() {
138    if (!this.hasErrorTarget) return
139    this.errorTarget.textContent = this.constructor.RATING_ERROR_MESSAGE
140    this.errorTarget.hidden = false
141  }
142
143  #clearError() {
144    if (!this.hasErrorTarget) return
145    this.errorTarget.textContent = ''
146    this.errorTarget.hidden = true
147  }
148
149  #emitRated(photoId, rating) {
150    window.dispatchEvent(
151      new CustomEvent('present-rating:rated', {
152        bubbles: true,
153        detail: { photoId: this.#normalizePhotoId(photoId), rating },
154      })
155    )
156  }
157
158  #updateActiveButtons() {
159    const active = normalizeRating(this.activeRating)
160    const map = {
161      [RATING_YES]: this.yesButtonTarget,
162      [RATING_MAYBE]: this.maybeButtonTarget,
163      [RATING_NO]: this.noButtonTarget,
164    }
165
166    for (const [value, btn] of Object.entries(map)) {
167      const rating = Number.parseInt(value, 10)
168      const isActive = active === rating
169      btn.classList.toggle('is-active', isActive)
170      btn.setAttribute('aria-pressed', isActive ? 'true' : 'false')
171      btn.style.removeProperty('border-color')
172    }
173  }
174
175  #setButtonsDisabled(disabled) {
176    ;[
177      this.yesButtonTarget,
178      this.maybeButtonTarget,
179      this.noButtonTarget,
180    ].forEach((btn) => {
181      btn.disabled = disabled
182    })
183  }
184
185  #syncFromStripSelection() {
186    const root = this.element.closest('.hd-PresentReview')
187    if (!root) return
188
189    const thumb = root.querySelector(
190      '.hd-PresentStrip__thumb .hd-PresentStrip__image.is-selected'
191    )
192    if (!thumb) return
193
194    const stripButton = thumb.closest('.hd-PresentStrip__thumb')
195    if (!stripButton?.dataset.photoId) return
196
197    this.onPhotoSelected({
198      detail: {
199        photoId: stripButton.dataset.photoId,
200        rating: Number.parseInt(stripButton.dataset.rating ?? '0', 10),
201      },
202    })
203  }
204
205  #normalizePhotoId(id) {
206    if (id === undefined || id === null || id === '') return ''
207    return String(id)
208  }
209}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.