Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:27 +03:30
co-authored by Cursor
commit 1153e8e78f
178 changed files with 30517 additions and 0 deletions
+838
View File
@@ -0,0 +1,838 @@
"use client";
import {
useEffect,
useId,
useRef,
useState,
type FormEvent,
} from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import ImageCropDialog from "@/components/ImageCropDialog";
import WebsiteLinkGenerator from "@/components/WebsiteLinkGenerator";
import {
getWebsiteHomeMedia,
newMediaItem,
saveWebsiteHomeMedia,
uploadWebsiteMedia,
type WebsiteHomeMedia,
type WebsiteMediaItem,
} from "@/services/website-media";
import styles from "./WebsiteImagesPage.module.css";
type SectionKey =
| "slider"
| "standingBanners"
| "categories"
| "menuImages"
| "aboutUs"
| "banners";
type PixelSize = { width: number; height: number };
type SlotGroup = {
size: PixelSize;
indexes: number[];
};
type SectionDef = {
key: SectionKey;
label: string;
kind: "list" | "fixed";
itemCount?: number;
/** Default upload / preview size */
size: PixelSize;
/** Per-index size overrides when not using groups */
sizes?: PixelSize[];
/** Same-row size groups (e.g. homepage banners) */
groups?: SlotGroup[];
fit: "contain" | "cover";
};
type EditTarget = {
key: SectionKey;
index: number;
};
const SECTIONS: SectionDef[] = [
{
key: "slider",
label: "Slider",
kind: "list",
size: { width: 450, height: 450 },
fit: "contain",
},
{
key: "standingBanners",
label: "Standing banners",
kind: "fixed",
itemCount: 2,
size: { width: 600, height: 600 },
fit: "contain",
},
{
key: "categories",
label: "Categories",
kind: "list",
size: { width: 600, height: 600 },
fit: "contain",
},
{
key: "menuImages",
label: "Menu images",
kind: "fixed",
itemCount: 4,
size: { width: 600, height: 600 },
fit: "contain",
},
{
key: "aboutUs",
label: "About us",
kind: "fixed",
itemCount: 1,
size: { width: 979, height: 800 },
fit: "contain",
},
{
key: "banners",
label: "Banners",
kind: "fixed",
itemCount: 5,
size: { width: 650, height: 650 },
// Matches homepage: wide featured · 3 small cards · export strip
groups: [
{ size: { width: 650, height: 650 }, indexes: [0] },
{ size: { width: 900, height: 900 }, indexes: [1, 2, 3] },
{ size: { width: 593, height: 640 }, indexes: [4] },
],
fit: "contain",
},
];
function formatResolution(size: PixelSize) {
return `${size.width} × ${size.height}`;
}
function sizeForItem(section: SectionDef, index: number): PixelSize {
const fromGroup = section.groups?.find((group) =>
group.indexes.includes(index),
);
if (fromGroup) return fromGroup.size;
return section.sizes?.[index] ?? section.size;
}
function previewWidth(size: PixelSize) {
const aspect = size.width / size.height;
if (aspect >= 1.4) return 220;
if (aspect >= 0.9) return 140;
return 110;
}
function getItems(media: WebsiteHomeMedia, key: SectionKey): WebsiteMediaItem[] {
if (key === "aboutUs") return [media.aboutUs];
return media[key];
}
function withItems(
media: WebsiteHomeMedia,
key: SectionKey,
items: WebsiteMediaItem[],
): WebsiteHomeMedia {
if (key === "aboutUs") {
return { ...media, aboutUs: items[0] ?? media.aboutUs };
}
return { ...media, [key]: items };
}
function emptySlot(partial?: Partial<WebsiteMediaItem>): WebsiteMediaItem {
return newMediaItem({
imageUrl: "",
linkUrl: "/",
title: "",
subtitle: "",
cta: "",
...partial,
});
}
function padFixed(items: WebsiteMediaItem[], count: number) {
const next = items.slice(0, count);
while (next.length < count) next.push(emptySlot());
return next;
}
export default function WebsiteImagesPage() {
const [media, setMedia] = useState<WebsiteHomeMedia | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [editTarget, setEditTarget] = useState<EditTarget | null>(null);
const [removeTarget, setRemoveTarget] = useState<EditTarget | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError("");
try {
const data = await getWebsiteHomeMedia();
if (!cancelled) setMedia(data);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "Could not load website images.",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
async function persist(next: WebsiteHomeMedia) {
setSaving(true);
setStatus("");
setError("");
try {
const saved = await saveWebsiteHomeMedia(next);
setMedia(saved);
setStatus("Saved");
return saved;
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save.");
throw err;
} finally {
setSaving(false);
}
}
async function handleSaveItem(
target: EditTarget,
values: {
imageUrl: string;
title: string;
subtitle: string;
cta: string;
linkUrl: string;
},
) {
if (!media) return;
const section = SECTIONS.find((row) => row.key === target.key);
if (!section) return;
let items = [...getItems(media, target.key)];
if (section.kind === "fixed" && section.itemCount) {
items = padFixed(items, section.itemCount);
}
const existing = items[target.index];
const nextItem: WebsiteMediaItem = {
...(existing ?? emptySlot()),
imageUrl: values.imageUrl,
title: values.title,
subtitle: values.subtitle,
cta: values.cta,
linkUrl: values.linkUrl || "/",
};
if (section.kind === "list" && !existing) {
items.push(nextItem);
} else {
items[target.index] = nextItem;
}
if (section.kind === "fixed" && section.itemCount) {
items = padFixed(items, section.itemCount);
}
const next = withItems(media, target.key, items);
setMedia(next);
await persist(next);
setEditTarget(null);
}
async function handleRemove(target: EditTarget) {
if (!media) return;
const section = SECTIONS.find((row) => row.key === target.key);
if (!section) return;
let items = [...getItems(media, target.key)];
if (section.kind === "list") {
items = items.filter((_, index) => index !== target.index);
if (target.key === "slider" && items.length === 0) {
items = [emptySlot({ title: "New slide", cta: "browse producers" })];
}
} else {
if (section.itemCount) items = padFixed(items, section.itemCount);
items[target.index] = emptySlot({ id: items[target.index]?.id });
}
const next = withItems(media, target.key, items);
setMedia(next);
setRemoveTarget(null);
await persist(next);
}
if (loading) {
return <div className={styles.loading}>Loading website images…</div>;
}
if (!media) {
return (
<div className={styles.loading} role="alert">
{error || "Could not load website images."}
</div>
);
}
const editingSection = editTarget
? SECTIONS.find((row) => row.key === editTarget.key)
: null;
const editingItems = editTarget ? getItems(media, editTarget.key) : [];
const editingItem = editTarget ? editingItems[editTarget.index] : null;
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>Website images</h1>
<p className={styles.lead}>
Homepage assets at their website pixel sizes — add or replace via
cropper.
</p>
</div>
<div className={styles.headActions}>
{status ? (
<span className={`${styles.status} ${styles.statusOk}`}>{status}</span>
) : null}
{error ? (
<span className={`${styles.status} ${styles.statusError}`}>
{error}
</span>
) : null}
</div>
</header>
{SECTIONS.map((section) => {
const raw = getItems(media, section.key);
const items =
section.kind === "fixed" && section.itemCount
? padFixed(raw, section.itemCount)
: raw;
const addSize = sizeForItem(section, items.length);
const addWidth = previewWidth(addSize);
const showListPlus = section.kind === "list";
const compactPlus = showListPlus && items.length > 0;
const thumbFitClass = `${styles.thumbTransparent} ${
section.fit === "contain" ? styles.thumbContain : styles.thumbCover
}`;
const groups = section.groups;
const hasGroups = Boolean(groups?.length);
function renderSlot(index: number, item: WebsiteMediaItem) {
const filled = Boolean(item.imageUrl?.trim());
const size = sizeForItem(section, index);
const width = previewWidth(size);
const aspect = `${size.width} / ${size.height}`;
return (
<div
key={`${section.key}-${item.id}-${index}`}
className={styles.cell}
style={{ width }}
>
{filled ? (
<>
<button
type="button"
className={`${styles.thumb} ${thumbFitClass}`}
style={{ aspectRatio: aspect }}
aria-label={`Edit ${section.label} ${index + 1}`}
disabled={saving}
onClick={() => setEditTarget({ key: section.key, index })}
>
<img src={item.imageUrl} alt="" />
</button>
<div className={styles.footer}>
<p className={styles.cellIndex}>{index + 1}</p>
<div className={styles.controls}>
<button
type="button"
className={styles.iconBtn}
aria-label="Edit"
disabled={saving}
onClick={() =>
setEditTarget({ key: section.key, index })
}
>
<EditIcon />
</button>
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label="Remove"
disabled={saving}
onClick={() =>
setRemoveTarget({ key: section.key, index })
}
>
<RemoveIcon />
</button>
</div>
</div>
</>
) : (
<button
type="button"
className={`${styles.thumb} ${styles.thumbEmpty}`}
style={{ aspectRatio: aspect }}
aria-label={`Add ${section.label} ${index + 1}`}
disabled={saving}
onClick={() => setEditTarget({ key: section.key, index })}
>
<span className={styles.plus}>+</span>
</button>
)}
</div>
);
}
return (
<section key={section.key} className={styles.section}>
<div className={styles.sectionHead}>
<h2 className={styles.sectionTitle}>{section.label}</h2>
<p className={styles.sectionMeta}>
{section.key}
{" · "}
{section.kind === "list"
? "Duplicatable"
: `Fixed · ${section.itemCount}`}
{!hasGroups ? ` · ${formatResolution(section.size)}` : null}
</p>
</div>
<div className={styles.track}>
{showListPlus ? (
<div
className={`${styles.cell} ${compactPlus ? styles.cellCompact : ""}`}
style={compactPlus ? undefined : { width: addWidth }}
>
<button
type="button"
className={`${styles.thumb} ${styles.thumbEmpty} ${
compactPlus ? styles.thumbCompact : ""
}`}
style={
compactPlus
? {
minHeight: Math.round(
addWidth / (addSize.width / addSize.height),
),
}
: {
aspectRatio: `${addSize.width} / ${addSize.height}`,
}
}
aria-label={`Add ${section.label}`}
disabled={saving}
onClick={() =>
setEditTarget({ key: section.key, index: items.length })
}
>
<span className={styles.plus}>+</span>
</button>
</div>
) : null}
{hasGroups
? groups!.map((group, groupIndex) => (
<div key={`group-${groupIndex}`} className={styles.group}>
{groupIndex > 0 ? (
<div className={styles.groupDivider} aria-hidden />
) : null}
<div className={styles.groupBody}>
<p className={styles.groupSize}>
{formatResolution(group.size)}
</p>
<div className={styles.groupItems}>
{group.indexes.map((index) => {
const item = items[index] ?? emptySlot();
return renderSlot(index, item);
})}
</div>
</div>
</div>
))
: items.map((item, index) => renderSlot(index, item))}
</div>
</section>
);
})}
<WebsiteMediaEditModal
open={Boolean(editTarget && editingSection)}
sectionLabel={editingSection?.label ?? ""}
size={
editTarget && editingSection
? sizeForItem(editingSection, editTarget.index)
: { width: 600, height: 600 }
}
fit={editingSection?.fit ?? "contain"}
initial={editingItem}
busy={saving}
onClose={() => {
if (!saving) setEditTarget(null);
}}
onSave={async (values) => {
if (!editTarget) return;
await handleSaveItem(editTarget, values);
}}
/>
<ConfirmDialog
open={Boolean(removeTarget)}
title="Remove image"
message="Clear this slot? You can add a new image later."
confirmLabel="Remove"
danger
busy={saving}
onClose={() => {
if (!saving) setRemoveTarget(null);
}}
onConfirm={() => {
if (removeTarget) void handleRemove(removeTarget);
}}
/>
</div>
);
}
function EditIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
}
function RemoveIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
function WebsiteMediaEditModal({
open,
sectionLabel,
size,
fit,
initial,
busy,
onClose,
onSave,
}: {
open: boolean;
sectionLabel: string;
size: PixelSize;
fit: "contain" | "cover";
initial: WebsiteMediaItem | null | undefined;
busy: boolean;
onClose: () => void;
onSave: (values: {
imageUrl: string;
title: string;
subtitle: string;
cta: string;
linkUrl: string;
}) => Promise<void>;
}) {
const resolution = formatResolution(size);
const aspect = size.width / size.height;
const titleId = useId();
const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState("");
const [cta, setCta] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [cropSrc, setCropSrc] = useState<string | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [localError, setLocalError] = useState("");
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (!open) return;
setTitle(initial?.title ?? "");
setSubtitle(initial?.subtitle ?? "");
setCta(initial?.cta ?? "");
setLinkUrl(initial?.linkUrl ?? "");
setImageUrl(initial?.imageUrl ?? "");
setPendingFile(null);
setLocalError("");
setCropSrc(null);
setCropOpen(false);
}, [open, initial]);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy && !cropOpen) onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open, busy, cropOpen, onClose]);
if (!open) return null;
async function submit(event: FormEvent) {
event.preventDefault();
if (!imageUrl && !pendingFile) {
setLocalError("Add an image first.");
return;
}
setLocalError("");
setUploading(true);
try {
let nextUrl = imageUrl;
if (pendingFile) {
nextUrl = await uploadWebsiteMedia(pendingFile);
}
await onSave({
imageUrl: nextUrl,
title: title.trim(),
subtitle: subtitle.trim(),
cta: cta.trim(),
linkUrl: linkUrl.trim() || "/",
});
} catch (err) {
setLocalError(err instanceof Error ? err.message : "Could not save.");
} finally {
setUploading(false);
}
}
const working = busy || uploading;
return (
<>
<div className={styles.overlay} role="presentation" onClick={onClose}>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.modalHead}>
<div>
<p className={styles.kicker}>Website image</p>
<h2 id={titleId} className={styles.modalTitle}>
{initial?.imageUrl ? "Edit image" : "Add image"}
</h2>
<p className={styles.modalHint}>
{sectionLabel} · {resolution}
</p>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
disabled={working}
onClick={onClose}
>
×
</button>
</div>
<form className={styles.form} onSubmit={(event) => void submit(event)}>
<div className={styles.imageBlock}>
<div className={styles.imageLabelRow}>
<span className={styles.imageLabel}>Image</span>
<span className={styles.imageLabel}>{resolution}</span>
</div>
{imageUrl ? (
<div className={styles.cropPreviewWrap}>
<div
className={`${styles.cropPreview} ${styles.cropPreviewTransparent} ${
fit === "contain"
? styles.cropPreviewContain
: styles.cropPreviewCover
}`}
style={{ aspectRatio: `${size.width} / ${size.height}` }}
>
<img src={imageUrl} alt="" />
</div>
<button
type="button"
className={styles.changeImage}
disabled={working}
onClick={() => fileRef.current?.click()}
>
Change image
</button>
</div>
) : (
<button
type="button"
className={styles.pickImage}
style={{ aspectRatio: `${size.width} / ${size.height}` }}
disabled={working}
onClick={() => fileRef.current?.click()}
>
<span className={styles.pickPlus}>+</span>
<span className={styles.pickMeta}>{resolution}</span>
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className={styles.fileInput}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const url = URL.createObjectURL(file);
setCropSrc(url);
setCropOpen(true);
}}
/>
</div>
<label className={styles.field}>
<span>Title EN</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Title"
disabled={working}
/>
</label>
<label className={styles.field}>
<span>Subtext</span>
<textarea
value={subtitle}
onChange={(event) => setSubtitle(event.target.value)}
placeholder="Short supporting text"
disabled={working}
rows={3}
/>
</label>
<label className={styles.field}>
<span>Link text</span>
<input
value={cta}
onChange={(event) => setCta(event.target.value)}
placeholder="browse producers"
disabled={working}
/>
</label>
<div className={styles.linkRow}>
<label className={`${styles.field} ${styles.linkField}`}>
<span>Link value</span>
<input
value={linkUrl}
onChange={(event) => setLinkUrl(event.target.value)}
placeholder="/businesses"
dir="ltr"
disabled={working}
/>
</label>
<WebsiteLinkGenerator
disabled={working}
onPick={(url) => setLinkUrl(url)}
/>
</div>
{localError ? (
<p className={styles.modalError} role="alert">
{localError}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`outline-btn ${styles.secondaryBtn}`}
disabled={working}
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.primaryBtn}`}
disabled={working || (!imageUrl && !pendingFile)}
>
{working ? "…" : "Save"}
</button>
</div>
</form>
</div>
</div>
<ImageCropDialog
open={cropOpen}
imageSrc={cropSrc}
title={`Crop · ${resolution}`}
aspect={aspect}
outputType="image/png"
fileName={`${sectionLabel.toLowerCase().replace(/\s+/g, "-")}-image`}
onCancel={() => {
if (cropSrc?.startsWith("blob:")) URL.revokeObjectURL(cropSrc);
setCropOpen(false);
setCropSrc(null);
}}
onComplete={(file, previewUrl) => {
if (cropSrc?.startsWith("blob:")) URL.revokeObjectURL(cropSrc);
setPendingFile(file);
setImageUrl(previewUrl);
setCropOpen(false);
setCropSrc(null);
}}
/>
</>
);
}