Source: profile/index.mjs

// Import our custom CSS
import "../../scss/styles.scss";

// Import all of Bootstrap's JS
// eslint-disable-next-line no-unused-vars
import * as bootstrap from "bootstrap";

import {
  API_BASE,
  API_KEY,
  API_LISTINGS_PROFILE,
  API_GET_LISTINGS_PARAMS,
  API_DATA_PROFILE,
  API_BIDS_PROFILE,
} from "../settings.mjs";
import { load, save } from "../shared/storage.mjs";
import { ErrorHandler } from "../shared/errorHandler.mjs";
import { sanitize } from "../shared/sanitize.mjs";
import { getProfileInfo } from "../shared/profileInfo.mjs";
import { checkUserAuth } from "../shared/checkUserAuth.mjs";
import { displaySpinner } from "../shared/displaySpinner.mjs";
import { displayError } from "../shared/displayErrorMsg.mjs";
import { countDown } from "../shared/calcCountdown.mjs";
import { daysOfWeek } from "../shared/daysOfWeek.mjs";

/** @typedef {object} GetProfileListingDataResponse
// @type {object} 
 * @property {string} id
 * @property {string} title
 * @property {string} description
 * @property {object[]} media
 * @property {string} media.url
 * @property {string} media.alt
* @property {string[]} tags
 * @property {string} created
 * @property {string} updated
 * @property {string} endsAt
 * @property {object} seller
 * @property {string} seller.name
 * @property {string} seller.email
 * @property {string} seller.bio
 * @property {object} seller.avatar
 * @property {string} seller.avatar.url
 * @property {string} seller.avatar.alt
 * @property {object} seller.banner
 * @property {string} seller.banner.url
 * @property {string} seller.banner.alt
 * @property {object} _count
 * @property {number} _count.bids
 */

/** @typedef  GetProfileListingMetaResponse
// @type {object} 
 * @property {boolean} isFirstPage
 * @property {boolean} isLastPage
 * @property {number} currentPage
 * @property {null} previousPage
 * @property {null} nextPage
 * @property {number} pageCount
 * @property {number} totalCount
 */

/** @typedef {object} GetProfileListingsResponse
 * @property {Array<GetProfileListingDataResponse>} data
 * @property {GetProfileListingMetaResponse} meta
 */

// -------------------------------------------------

/** @type {Array<GetProfileListingDataResponse>} */
let dataProfile = [];

/** @type {Array<GetProfileBidsDataResponse>} */
let bidsProfile = [];

// --------------------------------------------------

/** @typedef {object} GetProfileDataResponse
// @type {object}
 * @property {string} name
 * @property {string} email
 * @property {string} bio
 * @property {object} avatar
 * @property {string} avatar.url
 * @property {string} avatar.alt
 * @property {object} banner
 * @property {string} banner.url
 * @property {string} banner.alt
 * @property {number} credits
 * @property {object} _count
 * @property {number} _count.listings
 * @property {number} _count.wins
 */

/** @typedef {object} GetProfileMetaResponse
 * @type {object}
 * @property {boolean} isFirstPage
 * @property {boolean} isLastPage
 * @property {number} currentPage
 * @property {null} previousPage
 * @property {null} nextPage
 * @property {number} pageCount
 * @property {number} totalCount
 */

/** @typedef {object} GetProfileResponse
 * @property {GetProfileDataResponse} data
 * @property {GetProfileMetaResponse} meta
 */

// --------------------------------------------------

/** @typedef {object} GetProfileDataResponse2
 *  @type {object}
 * @property {string} name
 * @property {string} email
 * @property {string} bio
 * @property {object} avatar
 * @property {string} avatar.url
 * @property {string} avatar.alt
 * @property {object} banner
 * @property {string} banner.url
 * @property {string} banner.alt
 * @property {number} credits
 * @property {object} _count
 * @property {number} _count.listings
 * @property {number} _count.wins
 */

/** @typedef {object} GetProfileMetaResponse2
 * @type {object}
 */

/** @typedef {object} GetProfileResponse2
 * @property {GetProfileDataResponse2} data
 * @property {GetProfileMetaResponse2} meta
 */

// --------------------------------------------------

/** @typedef {object} GetProfileBidsDataResponse
/* @type {object}
 * @property {string} id
 * @property {number} amount
 * @property {object} bidder
 * @property {string} bidder.name
 * @property {string} bidder.email
 * @property {null} bidder.bio
 * @property {object} bidder.avatar
 * @property {string} bidder.avatar.url
 * @property {string} bidder.avatar.alt
 * @property {object} bidder.banner
 * @property {string} bidder.banner.url
 * @property {string} bidder.banner.alt
 * @property {string} created
 * @property {object} listing
 * @property {string} listing.id
 * @property {string} listing.title
 * @property {string} listing.description
 * @property {string} listing.created
 * @property {string} listing.updated
 * @property {string} listing.endsAt
 * @property {string[]} listings.tags
 */

/** @typedef  GetProfileBidsMetaResponse
/* @type {object}
 * @property {boolean} isFirstPage
 * @property {boolean} isLastPage
 * @property {number} currentPage
 * @property {null} previousPage
 * @property {null} nextPage
 * @property {number} pageCount
 * @property {number} totalCount
 */

/** @typedef {object} GetProfileBidsResponse
 * @property {Array<GetProfileBidsDataResponse>} data
 * @property {GetProfileBidsMetaResponse} meta
 */
// --------------------------------------------------

/** @type {string} */
let name;

export function init() {
  checkUserAuth();
  addEventToEditProfile();

  const { avatarUrl, name: username, bio } = getProfileInfo();

  if (username) {
    name = username;

    updateProfile(avatarUrl, username, bio);

    displayListings(username);

    displayBids(username);

    fetchUserMetaData(username);
  }

  /** @type {HTMLSelectElement} */
  const tabSortListings = document.querySelector("#order-By-Listings");
  tabSortListings.addEventListener("change", listingsHandleOrderBy);

  /** @type {HTMLSelectElement} */
  const tabSortBids = document.querySelector("#order-By-Bids");
  tabSortBids.addEventListener("change", bidsHandleOrderBy);
}

/**
 * @description Sort the user array listings by a specified key
 * @method listingsHandleOrderBy
 * @param {Event} ev The event from the `select` element.
 * @example
 * // if the select value is 'title', it return the listings sorted alphabetically a-z.
 * // if the select value is 'newest', it returns the newest listings first.
 * // if the select value is 'oldest', it returns the oldest listings first.
 */
function listingsHandleOrderBy(ev) {
  const select = /** @type {HTMLSelectElement} */ (ev.currentTarget);
  const oby = select.value;

  if (oby === "title") {
    dataProfile.sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1));
  } else if (oby === "newest") {
    dataProfile.sort(function (v1, v2) {
      return new Date(v2.created).getTime() - new Date(v1.created).getTime();
    });
  } else if (oby === "oldest") {
    dataProfile.sort(function (v1, v2) {
      return new Date(v1.created).getTime() - new Date(v2.created).getTime();
    });
  }
  updateListings(dataProfile);
}

/**
 * @description Sort the user array bids by a specified key
 * @method listingsHandleOrderBy
 * @param {Event} ev The event from the `select` element.
 * @example
 * // if the select value is 'title', it return the bids sorted alphabetically a-z.
 * // if the select value is 'newest', it returns the newest bids first.
 * // if the select value is 'oldest', it returns the oldest bids first.
 */
function bidsHandleOrderBy(ev) {
  const select = /** @type {HTMLSelectElement} */ (ev.currentTarget);
  const oby = select.value;

  if (oby === "title") {
    bidsProfile.sort((a, b) => (a.listing.title.toLowerCase() > b.listing.title.toLowerCase() ? 1 : -1));
  } else if (oby === "newest") {
    bidsProfile.sort(function (v1, v2) {
      return new Date(v2.created).getTime() - new Date(v1.created).getTime();
    });
  } else if (oby === "oldest") {
    bidsProfile.sort(function (v1, v2) {
      return new Date(v1.created).getTime() - new Date(v2.created).getTime();
    });
  }
  updateBids(bidsProfile);
}

/**
 * @description Send a request to API
 * @async
 * @function displayListings
 * @param {string} username  The user name
 * @returns {Promise<GetProfileListingDataResponse[]|null|undefined>} If response is ok, return listings info. If response is not ok, return null. Returns undefined for unexpected errors.
 *
 * */
export async function displayListings(username) {
  try {
    displaySpinner(true, "#spinnerListings");
    displayError(false, "#errorListings");

    const url = API_BASE + API_LISTINGS_PROFILE(username) + API_GET_LISTINGS_PARAMS;

    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${load("token")}`,
        "X-Noroff-API-Key": API_KEY,
        "Content-Type": "application/json",
      },
      method: "GET",
    });

    if (response.ok) {
      /** @type {GetProfileListingsResponse} */
      const listingsData = await response.json();
      dataProfile = listingsData.data;

      updateListings(dataProfile);
      return dataProfile;
    }

    const eh = new ErrorHandler(response);
    const msg = await eh.getErrorMessage();
    displayError(true, "#errorListings", msg);

    return null;
  } catch (ev) {
    displayError(true, "#errorListings", "Could not show the listings!");
  } finally {
    displaySpinner(false, "#spinnerListings");
  }
}

/**
 * @description Map a listing to html content
 * @method updateListings
 * @param {Array<GetProfileListingDataResponse>} data The user listings info.
 */
export async function updateListings(data) {
  /** @type {HTMLDivElement} */
  const listings = document.querySelector("#listings");
  listings.innerHTML = "";

  if (data.length === 0) {
    listings.innerHTML = "No listings available!";
  } else {
    for (let i = 0; i < data.length; i++) {
      const item = data[i];

      /** @type {HTMLTemplateElement} */
      const template = document.querySelector("#listing");
      const listing = /** @type {HTMLDivElement} */ (template.content.cloneNode(true));

      listing.querySelector("article").dataset.id = String(item.id);

      const endsAtDate = new Date(item.endsAt);
      countDown(1 * 1000, endsAtDate, ({ distance, days, hours, minutes, seconds }) => {
        const deadline = document.querySelector(`article[data-id="${item.id}"] #deadlineListings`);
        if (distance > 0) {
          const dayOfWeek = daysOfWeek[endsAtDate.getDay()];
          deadline.innerHTML = `${days}d ${hours}h ${minutes}m ${seconds}s Left, ${dayOfWeek}`;
        } else {
          deadline.innerHTML = "EXPIRED";
        }
      });

      listing.querySelector("h5").innerText = item.seller.name; //item.id
      /** @type {HTMLImageElement} */
      const authorImg = listing.querySelector("#sellerImg");
      authorImg.src = item.seller.avatar.url;

      const { media } = item;

      if (media) {
        const listingImgs = listing.querySelector("#listingImgs");

        /** @type {HTMLTemplateElement} */
        const templateCarousel = document.querySelector("#slider-carousel");
        const CloneCarousel = /** @type {HTMLDivElement} */ (templateCarousel.content.cloneNode(true));

        const carouselNewId = `carousel_${item.id}`;
        CloneCarousel.querySelector(".carousel").id = carouselNewId;

        /** @type {HTMLButtonElement} */
        const prev = CloneCarousel.querySelector(".carousel-control-prev");
        prev.dataset.bsTarget = `#${carouselNewId}`;

        /** @type {HTMLButtonElement} */
        const next = CloneCarousel.querySelector(".carousel-control-next");
        next.dataset.bsTarget = `#${carouselNewId}`;

        const imgs = CloneCarousel.querySelector(".carousel-inner");
        imgs.innerHTML = "";

        for (let i = 0; i < media.length; i++) {
          const img = media[i];

          imgs.innerHTML += `<div class="carousel-item ${i === 0 ? "active" : ""}"><img src="${img.url}" class="d-block w-100 card-img" alt="${img.alt}"></div>`;
        }

        if (media.length <= 1) {
          prev.style.display = "none";
          next.style.display = "none";
        }

        listingImgs.appendChild(CloneCarousel);
      }

      listing.querySelector("#bodyTitle").innerHTML = sanitize(item.title);
      listing.querySelector("#bodyListing").innerHTML = sanitize(item.description);

      let date = new Date(item.created);
      /** @type Intl.DateTimeFormatOptions */
      const options = {
        // weekday: "long",
        year: "numeric",
        month: "long",
        day: "numeric",
      };
      // `BCP 47 language tag` => no-NO
      let dateString = date.toLocaleDateString("no-NO", options);
      listing.querySelector("#dateListing").innerHTML = dateString;

      /** @type {HTMLInputElement} */
      const txtTitle = listing.querySelector("#listingTitle");
      txtTitle.value = item.title;

      /** @type {HTMLTextAreaElement} */
      const txtBody = listing.querySelector("#listingText");
      txtBody.value = item.description;

      listings.appendChild(listing);
    }
  }
}

/**
 * @description Send a request to API
 * @async
 * @function fetchUserMetaData
 * @param {string} username  The user name
 * @returns {Promise<GetProfileDataResponse|null|undefined>} If response is ok, return profile meta info. If response is not ok, return null. Returns undefined for unexpected errors.
 * */
export async function fetchUserMetaData(username) {
  try {
    displaySpinner(true, "#spinnerProfileData");
    displayError(false, "#errorProfileData");

    const url = API_BASE + API_DATA_PROFILE(username);

    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${load("token")}`,
        "X-Noroff-API-Key": API_KEY,
        "Content-Type": "application/json",
      },
      method: "GET",
    });

    if (response.ok) {
      /** @type {GetProfileResponse} */
      const profileData = await response.json();
      const profileInfo = profileData.data;
      displayUserMetaData(profileInfo);
      return profileInfo;
    }

    const eh = new ErrorHandler(response);
    const msg = await eh.getErrorMessage();
    displayError(true, "#errorProfileData", msg);
    return null;
  } catch (ev) {
    displayError(true, "#errorProfileData", "Could not show the profile data!");
  } finally {
    displaySpinner(false, "#spinnerProfileData");
  }
}

/**
 * @description Displays the number of wins, the number of listings and the number of credits.
 * @method displayUserMetaData
 * @param {GetProfileDataResponse} profileInfo user profile info
 */
async function displayUserMetaData(profileInfo) {
  /** @type {HTMLDivElement} */
  const totFollowing = document.querySelector("#totWins");
  totFollowing.innerText = String(profileInfo._count.wins);

  /** @type {HTMLDivElement} */
  const totFollowers = document.querySelector("#totListings");
  totFollowers.innerText = String(profileInfo._count.listings);

  /** @type {HTMLDivElement} */
  const totListings = document.querySelector("#totCredits");
  totListings.innerText = String(profileInfo.credits);
}

/**
 * @description Set the event to modal dialog
 * @method addEventToEditProfile
 */
async function addEventToEditProfile() {
  displayError(false, "#errorProfile");

  const showBtn = document.querySelector("#show-dialog");
  const profileForm = document.querySelector("#editAvatar");

  /** @type {HTMLDialogElement} */
  const dialog = document.querySelector("#dialog");
  const closeBtn = dialog.querySelector("#close");

  let { avatarUrl } = getProfileInfo();

  showBtn.addEventListener("click", () => {
    /** @type {HTMLImageElement} */
    const authorImg = document.querySelector("#authorAvatar");
    authorImg.src = avatarUrl;

    dialog.showModal();
  });

  closeBtn.addEventListener("click", (e) => {
    e.preventDefault();
    dialog.close();
  });

  //Update avatar image
  profileForm.addEventListener("submit", async (ev) => {
    ev.preventDefault();

    const form = /** @type {HTMLFormElement} */ (ev.currentTarget);

    avatarUrl = form.elements["avatarUrl"].value;
    const result = await fetchUpdateProfile(name, avatarUrl);
    if (result) {
      updateProfile(result.avatar.url, result.name, result.bio);

      dialog.close();
    }
  });
}

/**
 * @description Update user avatar, name and bio
 * @method updateProfile
 * @param {string} avatarUrl  The url of the user image/avatar
 * @param {string} username  The user name
 * @param {string} bio  The user bio
 */
function updateProfile(avatarUrl, username, bio) {
  /** @type {HTMLImageElement} */
  const img = document.querySelector("#author-image");
  img.src = avatarUrl;

  /** @type {HTMLHeadingElement} */
  const authorInfoName = document.querySelector("#author-info h1");
  authorInfoName.innerText = username;

  /** @type {HTMLParagraphElement} */
  const authorInfoBio = document.querySelector("#author-info p");
  authorInfoBio.innerHTML = bio;
}

/**
 * @description Send a request to API
 * @async
 * @function fetchUpdateProfile
 * @param {string} username  The user name
 * @param {string} avatarUrl  The url of the user image/avatar
 * @returns {Promise<GetProfileDataResponse2|null|undefined>} If response is ok, return profile info. If response is not ok, return null. Returns undefined for unexpected errors.
 * */
export async function fetchUpdateProfile(username, avatarUrl) {
  try {
    displaySpinner(true, "#spinnerProfileData");
    displayError(false, "#errorProfileData");

    const url = API_BASE + API_DATA_PROFILE(username);

    const request = {
      avatar: {
        url: avatarUrl,
        alt: "Author image",
      },
    };

    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${load("token")}`,
        "X-Noroff-API-Key": API_KEY,
        "Content-Type": "application/json",
      },
      method: "PUT",
      body: JSON.stringify(request),
    });

    if (response.ok) {
      /** @type {GetProfileResponse2} */
      const profileData = await response.json();

      const dataProfile = profileData.data;

      save("profile", dataProfile);

      return dataProfile;
    }

    const eh = new ErrorHandler(response);
    const msg = await eh.getErrorMessage();
    displayError(true, "#errorProfileData", msg);

    return null;
  } catch (ev) {
    displayError(true, "#errorProfileData", "Could not update the image!");
  } finally {
    displaySpinner(false, "#spinnerProfileData");
  }
}

/**
 * @description Send a request to API
 * @async
 * @function displayBids
 * @param {string} username  The user name
 * @returns {Promise<GetProfileBidsDataResponse[]|null|undefined>} If response is ok, return bids info. If response is not ok, return null. Returns undefined for unexpected errors.
 * */
export async function displayBids(username) {
  try {
    displaySpinner(true, "#spinnerBids");
    displayError(false, "#errorBids");

    const url = API_BASE + API_DATA_PROFILE(username) + API_BIDS_PROFILE;

    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${load("token")}`,
        "X-Noroff-API-Key": API_KEY,
        "Content-Type": "application/json",
      },
      method: "GET",
    });

    if (response.ok) {
      /** @type {GetProfileBidsResponse} */
      const profileBids = await response.json();
      bidsProfile = profileBids.data;

      updateBids(bidsProfile);
      return bidsProfile;
    }

    const eh = new ErrorHandler(response);
    const msg = await eh.getErrorMessage();
    displayError(true, "#errorBids", msg);

    return null;
  } catch (ev) {
    displayError(true, "#errorBids", "Could not show the bids!");
  } finally {
    displaySpinner(false, "#spinnerBids");
  }
}

/**
 * @description Map a listing to html content
 * @method updateBids
 * @param {Array<GetProfileBidsDataResponse>} data The user bids
 */
export async function updateBids(data) {
  /** @type {HTMLDivElement} */
  const bids = document.querySelector("#bids");
  bids.innerHTML = "";

  if (data.length === 0) {
    bids.innerHTML = "No bids available!";
  } else {
    for (let i = 0; i < data.length; i++) {
      const item = data[i];

      /** @type {HTMLTemplateElement} */
      const template = document.querySelector("#bid");
      const bid = /** @type {HTMLDivElement} */ (template.content.cloneNode(true));

      bid.querySelector("article").dataset.id = String(item.id);

      const endsAtDate = new Date(item.listing.endsAt);
      countDown(1 * 1000, endsAtDate, ({ distance, days, hours, minutes, seconds }) => {
        const deadline = document.querySelector(`article[data-id="${item.id}"] #deadlineBids`);
        if (distance > 0) {
          const dayOfWeek = daysOfWeek[endsAtDate.getDay()];
          deadline.innerHTML = `${days}d ${hours}h ${minutes}m ${seconds}s Left, ${dayOfWeek}`;
        } else {
          deadline.innerHTML = `<div class="text-danger fw-bold">EXPIRED<div/>`;
        }
      });

      bid.querySelector("#bodyBidTitle").innerHTML = sanitize(item.listing.title);

      bid.querySelector("#bodyBid").innerHTML = sanitize(item.listing.description);

      bid.querySelector("#amount").innerHTML = String(item.amount);

      /** @type Intl.DateTimeFormatOptions */
      const options = {
        // weekday: "long",
        year: "numeric",
        month: "numeric",
        day: "numeric",
      };

      const placedAtDate = new Date(item.created);
      let placedAtDateString = placedAtDate.toLocaleDateString("no-NO", options);
      bid.querySelector("#placedAt").innerHTML = placedAtDateString;

      // `BCP 47 language tag` => no-NO
      let endsAtDateString = endsAtDate.toLocaleDateString("no-NO", options);
      bid.querySelector("#endsAt").innerHTML = endsAtDateString;

      const accordionBid = `accordion_${item.id}`;

      /** @type {HTMLButtonElement} */
      const btnCollapseBid = bid.querySelector("#btnCollapseBid");
      btnCollapseBid.dataset.bsTarget = `#${accordionBid}`;

      /** @type {HTMLDivElement} */
      const descBidBox = bid.querySelector("#collapseBid");
      descBidBox.id = `${accordionBid}`;

      bids.appendChild(bid);
    }
  }
}