Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | 102x 14x 39x 9x 7x 5x 11x 8x 14x 12x 14x 12x 34x 13x 13x 13x 13x 7x 7x 13x 7x 6x 5x 5x 5x 6x 5x 4x 8x 7x 3x 6x 6x 12x 12x 12x 121x 6x 39x 12x 7x 6x 3x 3x 3x 7x 7x 7x 6x 4x 4x 4x 7x 7x 7x 3x 8x 8x 3x | // Helper functions for API data
import { buildImageUrl } from "@interioring/utils/format/image-url";
import {
ROOM_CATEGORIES,
CITIES,
CUSTOMER_SEGMENT_CONFIG,
PROPERTY_TYPE_LABELS,
BUDGET_RANGE_LABELS,
BUDGET_RANGE_LABELS_FULL,
ROOM_BUDGET_LABELS,
type CustomerSegmentConfig,
} from "./constants";
export function getCategoryLabel(category: string): string {
const found = ROOM_CATEGORIES.find((c) => c.value === category);
return found?.label ?? category;
}
export function getCityLabel(city: string): string {
const found = CITIES.find((c) => c.value === city);
return found?.label ?? city;
}
export function getCustomerSegmentBadge(
customerSegment: string | null,
): CustomerSegmentConfig | null {
if (!customerSegment) return null;
return CUSTOMER_SEGMENT_CONFIG[customerSegment] ?? null;
}
export function getPropertyTypeLabel(
propertyType: string | null,
): string | null {
if (!propertyType) return null;
return PROPERTY_TYPE_LABELS[propertyType] ?? propertyType;
}
export function getBudgetRangeLabel(
budgetRange: string | null,
full = false,
): string | null {
if (!budgetRange) return null;
const labels = full ? BUDGET_RANGE_LABELS_FULL : BUDGET_RANGE_LABELS;
return labels[budgetRange] ?? budgetRange;
}
// Indian short units, largest first: 1 Cr = 1,00,00,000; 1 L = 1,00,000.
const RUPEE_UNITS: [number, string][] = [
[1e7, "Cr"],
[1e5, "L"],
[1e3, "K"],
];
function rupeeUnit(value: number): [number, string] {
return RUPEE_UNITS.find(([size]) => value >= size) ?? [1, ""];
}
function rupeeAmount(value: number, size: number): string {
return String(Math.round((value / size) * 10) / 10);
}
/**
* A designer's own stated project value range (rupees) as one printed band:
* "₹2.5–8L", "₹80K–3.2L", "₹35L–2.5Cr", "From ₹2.5L", "Up to ₹8L".
* Null when neither end is a positive amount, or the ends are reversed.
*/
export function formatPriceBand(
min: number | null | undefined,
max: number | null | undefined,
): string | null {
const lo = typeof min === "number" && min > 0 ? min : null;
const hi = typeof max === "number" && max > 0 ? max : null;
const short = (v: number) => {
const [size, unit] = rupeeUnit(v);
return `${rupeeAmount(v, size)}${unit}`;
};
if (lo !== null && hi !== null) {
if (hi < lo) return null;
if (hi === lo) return `₹${short(lo)}`;
const [loSize, loUnit] = rupeeUnit(lo);
const [hiSize, hiUnit] = rupeeUnit(hi);
return loUnit === hiUnit
? `₹${rupeeAmount(lo, loSize)}–${rupeeAmount(hi, hiSize)}${hiUnit}`
: `₹${short(lo)}–${short(hi)}`;
}
if (lo !== null) return `From ₹${short(lo)}`;
if (hi !== null) return `Up to ₹${short(hi)}`;
return null;
}
export function getRoomBudgetLabel(
budgetSpent: string | null,
): string | null {
if (!budgetSpent) return null;
return ROOM_BUDGET_LABELS[budgetSpent] ?? budgetSpent;
}
// Returns the browser-facing API URL (PUBLIC_API_URL if set, otherwise API_URL)
// In Docker, API_URL points to the internal service name (http://api:6001) for SSR fetches,
// while PUBLIC_API_URL points to the host-accessible URL (http://localhost:7001) for browser use.
export function getPublicApiUrl(env: { API_URL: string; PUBLIC_API_URL?: string }): string {
return env.PUBLIC_API_URL || env.API_URL;
}
/**
* Concurrency-capped enrichment helper. Caps Promise.all parallelism to
* stay safely under Cloudflare Workers' 50-subrequest limit per request.
*
* Each chunk runs its items in parallel; chunks run sequentially. Failures
* inside a chunk surface per-item via Promise.allSettled — one bad row
* never fails the whole batch.
*
* Default chunkSize = 20 leaves headroom for the page's other subrequests
* (auth probe, KV reads, additional API calls). Bump down if a page makes
* many other fetches; bump up only if you're certain the worker is on a
* paid plan with the higher 1000-subrequest limit.
*/
export async function enrichChunked<T, U>(
items: T[],
fn: (item: T) => Promise<U>,
chunkSize = 20,
): Promise<Array<U | null>> {
const results: Array<U | null> = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const settled = await Promise.allSettled(chunk.map(fn));
for (const r of settled) {
results.push(r.status === "fulfilled" ? r.value : null);
}
}
return results;
}
export {
getRoomCategorySlug,
isKnownRoomTypeCode,
} from "@interioring/utils/constants/room-categories";
// Helper to build full image URL from path — thin wrapper over the shared
// builder in @interioring/utils/format/image-url.
//
// External origins (Unsplash, Pexels, etc.) are deliberately passed through
// raw: Cloudflare Image Resizing on `images-{env}.decorrocket.com` /
// `images.interioring.com` is not configured to allow external source
// origins, so wrapping them in `/cdn-cgi/image/...` returns HTTP 403 and
// breaks seed pro logos / blog photos. Re-enable wrapping for external
// origins only after the zone Image Resizing source allowlist includes the
// target hostname. (Contrast: pro-sites uses an allowlist policy — untrusted
// keys rendered on pro-owned pages.)
export function getImageUrl(
path: string,
apiUrl: string,
imageUrl?: string,
resizeParams?: { w?: number; h?: number; f?: string; q?: number; fit?: string },
): string {
return buildImageUrl(path, {
apiUrl,
imageUrl,
resizeParams,
external: { kind: "passthrough" },
});
}
// AVIF quality for every resized photo. Cloudflare's default is 85; at 65 the
// home hero photo was 39% smaller (58 -> 36 KB) and indistinguishable at 2x
// zoom. LqipPicture renders with it and the preload URLs below repeat it: a
// preload only counts when its URL matches the <img> byte for byte.
export const PHOTO_QUALITY = 65;
// Build a same-origin preload URL matching Astro's <Picture> cloudflare image format.
// Used for <link rel="preload"> to ensure the preloaded resource matches the rendered <img>.
//
// Format chosen = AVIF because Astro's <Picture> is configured with
// formats=['avif','webp'] and browsers pick the first supported <source>.
// Modern browsers (Chrome 85+, Firefox 93+, Safari 16+ — effectively all
// Interioring traffic) support AVIF and will load that, leaving a webp
// preload unused. That triggered a "preloaded but not used within a few
// seconds" console warning on every SSR page. AVIF preload matches what
// the browser actually loads; older browsers without AVIF support skip the
// preload and fall back to the webp <source> at parse time (one extra
// handshake worth of LCP delay on a tiny tail of users).
// Format: /cdn-cgi/image/onerror=redirect,width=W,height=H,quality=Q,format=avif/{full-image-url}
export function getAstroPreloadUrl(
path: string,
apiUrl: string,
imageUrl?: string,
params?: { w?: number; h?: number },
): string | undefined {
if (!path) return undefined;
// Without IMAGE_URL the runtime has no Cloudflare Image Resizing origin,
// so <Picture> renders the plain apiUrl-prefixed path (no /cdn-cgi wrap).
// Emitting a /cdn-cgi preload would point at a URL the browser never
// actually requests; the preload would always go unused and fire the
// "preloaded but not used" warning. Skip it in that case.
if (!imageUrl) return undefined;
// Get the base image URL (no resize params)
const baseUrl = getImageUrl(path, apiUrl, imageUrl);
Iif (!baseUrl) return undefined;
const w = params?.w ?? 800;
const h = params?.h ?? 500;
return `/cdn-cgi/image/onerror=redirect,width=${w},height=${h},quality=${PHOTO_QUALITY},format=avif/${baseUrl}`;
}
/**
* Build a responsive `imagesrcset` string for `<link rel="preload" as="image">`.
*
* Returns a comma-joined srcset of CDN URLs at multiple widths (defaults
* 400/800/1200), keyed off the priority `<Picture>`'s aspect ratio so the
* variants match what the browser will actually request via the rendered
* <img>'s srcset. Pair with `imagesizes` so the browser picks the right
* variant per viewport.
*
* Example:
* preloadImageSrcSet={getAstroPreloadSrcSet(path, apiUrl, imgUrl, {
* ratio: '4/3',
* widths: [400, 800, 1200],
* })}
* preloadImageSizes="(max-width: 768px) 100vw, 800px"
*/
export function getAstroPreloadSrcSet(
path: string,
apiUrl: string,
imageUrl?: string,
params?: { widths?: number[]; ratio?: string },
): string | undefined {
if (!path) return undefined;
if (!imageUrl) return undefined;
const baseUrl = getImageUrl(path, apiUrl, imageUrl);
Iif (!baseUrl) return undefined;
const widths = params?.widths ?? [400, 800, 1200];
const ratio = params?.ratio ?? "4/3";
const [rw, rh] = ratio.split("/").map(Number);
if (!rw || !rh) return undefined;
const entries = widths.map((w) => {
const h = Math.round((w * rh) / rw);
return `/cdn-cgi/image/onerror=redirect,width=${w},height=${h},quality=${PHOTO_QUALITY},format=avif/${baseUrl} ${w}w`;
});
return entries.join(", ");
}
|