shadimage
channel3
libformatsource: ok · hit

Format helpers

Price, currency, discount, domain, availability, and image helpers for Channel3 product data.

npx shadcn add "https://ui.trychannel3.com/r/format.json"
free

Where it goes

pricing

Use cases

saas-landing

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@channel3/sdk

Files1 · source cached 10/14/2026, 12:51:32 PM

registry/default/lib/format.ts
import type {
  OfferAvailabilityStatus,
  Price,
  ProductImage,
  ProductOffer,
} from "@channel3/sdk/resources";

/**
 * Format a numeric amount as a localized currency string. Falls back to a
 * plain `CODE 12.34` string when the runtime doesn't recognize the currency.
 */
export function formatCurrency(amount: number, currency: string, locale?: string): string {
  try {
    return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
  } catch {
    return `${currency} ${amount.toFixed(2)}`;
  }
}

export function formatPrice(price: Price, locale?: string): string {
  return formatCurrency(price.price, price.currency, locale);
}

export function isOnSale(price: Price): boolean {
  return typeof price.compare_at_price === "number" && price.compare_at_price > price.price;
}

export function discountPercent(price: Price): number | null {
  if (!isOnSale(price) || !price.compare_at_price) {
    return null;
  }
  return Math.round(((price.compare_at_price - price.price) / price.compare_at_price) * 100);
}

export function formatDomain(domain: string): string {
  return domain.replace(/^https?:\/\//, "").replace(/^www\./, "");
}

export function isInStock(status: OfferAvailabilityStatus): boolean {
  return status === "InStock";
}

const AVAILABILITY_LABELS: Record<OfferAvailabilityStatus, string> = {
  InStock: "In stock",
  OutOfStock: "Out of stock",
};

export function availabilityLabel(status: OfferAvailabilityStatus): string {
  return AVAILABILITY_LABELS[status];
}

/**
 * Display URL for an image. `preferCleaned` picks `cleaned_url` (the
 * background-removed square) when the API has one, and falls back to `url`.
 */
export function productImageUrl(
  image: ProductImage,
  { preferCleaned = false }: { preferCleaned?: boolean } = {},
): string {
  if (preferCleaned) {
    const cleaned = image.cleaned_url;
    if (cleaned) {
      return cleaned;
    }
  }
  return image.url;
}

export function pickImage(
  images: ReadonlyArray<ProductImage> | undefined,
): ProductImage | undefined {
  if (!images || images.length === 0) {
    return undefined;
  }
  return images.find((image) => image.is_main_image) ?? images[0];
}

/**
 * Hover/secondary image priority: a contextual shot (product worn or in use)
 * makes a far more compelling crossfade than another clean studio angle. Shot
 * types are tried in this order, then any remaining image, with reference shots
 * (size charts, packaging, etc.) excluded entirely.
 */
const HOVER_SHOT_PRIORITY: ReadonlyArray<NonNullable<ProductImage["shot_type"]>> = [
  "on_model",
  "lifestyle",
  "in_use",
  "flat_lay",
  "angle_view",
];

const HOVER_SHOT_EXCLUDE: ReadonlySet<NonNullable<ProductImage["shot_type"]>> = new Set([
  "size_chart",
  "packaging",
  "product_information",
  "merchant_information",
  "scale_reference",
]);

/**
 * Pick the best image to crossfade to on hover, preferring contextual shots
 * (see {@link HOVER_SHOT_PRIORITY}). Returns `undefined` when there's no
 * suitable second image.
 */
export function pickHoverImage(
  images: ReadonlyArray<ProductImage> | undefined,
  { excludeUrl }: { excludeUrl?: string } = {},
): ProductImage | undefined {
  if (!images || images.length === 0) {
    return undefined;
  }
  const candidates = images.filter(
    (image) =>
      image.url !== excludeUrl &&
      !(image.shot_type != null && HOVER_SHOT_EXCLUDE.has(image.shot_type)),
  );
  if (candidates.length === 0) {
    return undefined;
  }
  for (const shot of HOVER_SHOT_PRIORITY) {
    const match = candidates.find((image) => image.shot_type === shot);
    if (match) {
      return match;
    }
  }
  return candidates[0];
}

export function leadOffer(offers: ReadonlyArray<ProductOffer> | undefined): ProductOffer | undefined {
  if (!offers || offers.length === 0) {
    return undefined;
  }
  const byPrice = [...offers].sort((a, b) => a.price.price - b.price.price);
  return byPrice.find((offer) => isInStock(offer.availability)) ?? byPrice[0];
}

export function isSoldOut(offers: ReadonlyArray<ProductOffer> | undefined): boolean {
  return Boolean(offers && offers.length > 0 && !offers.some((o) => isInStock(o.availability)));
}

More from channel3

Browse all components in this library →