feat: consume MinIO presigned media URLs (feature/minio-integration)

icon/image/logo/cover_image are now MinIO object keys, not URLs -- every
serializer with one of these fields now returns a <field>_url sibling
that's an already-absolute, freshly presigned URL. Added those fields to
every affected type (StoreCategory, StoreListItem, ProductCategory,
ProductListItem) and switched every <img> call site to use the _url field
directly.

Deleted resolveMediaUrl/resolveClientMediaUrl and the
NEXT_PUBLIC_WINOFY_MEDIA_ORIGIN env var -- they existed only to turn
relative paths under the Winofy origin into absolute URLs, which no longer
applies now that media lives on a different host (MinIO) and comes back
pre-resolved.

Also fixes cart-item-row.tsx's product thumbnail, which had been commented
out (broken under the old relative-path assumption).

Restored image_url as a required non-optional field per the doc rather
than treating it as possibly-missing, since every serializer covered by
this migration always includes it (null when no image, not omitted).
This commit is contained in:
Afra ‌ 2026-08-18 13:32:30 +03:30
parent 6f207753be
commit e5b00c4d55
8 changed files with 29 additions and 37 deletions

View file

@ -1,7 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { winofyFetch, ApiError } from "@/lib/api/winofy";
import { resolveMediaUrl } from "@/lib/api/media";
import type { ProductDetail } from "@/types/api";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
@ -28,9 +27,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
</Link>
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-muted">
{product.image && (
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolveMediaUrl(product.image) ?? undefined} alt={product.name} className="h-full w-full object-cover" />
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
)}
</div>

View file

@ -1,7 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy";
import { resolveMediaUrl } from "@/lib/api/media";
import type { ProductListItem, StoreDetail } from "@/types/api";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
@ -35,17 +34,17 @@ export default async function StoreDetailPage({ params }: PageProps<"/stores/[uu
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-6">
<div className="overflow-hidden rounded-2xl border border-border">
<div className="h-32 w-full bg-muted">
{store.cover_image && (
{store.cover_image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolveMediaUrl(store.cover_image) ?? undefined} alt="" className="h-full w-full object-cover" />
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center gap-3">
{store.logo ? (
{store.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(store.logo) ?? undefined}
src={store.logo_url}
alt={store.name}
className="h-16 w-16 shrink-0 rounded-full border border-border object-cover"
/>
@ -109,10 +108,10 @@ export default async function StoreDetailPage({ params }: PageProps<"/stores/[uu
<article key={product.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-3">
<Link href={`/products/${product.uuid}`} className="flex flex-col gap-2">
<div className="aspect-square w-full overflow-hidden rounded-xl bg-muted">
{product.image && (
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(product.image) ?? undefined}
src={product.image_url}
alt={product.name}
className="h-full w-full object-cover"
/>

View file

@ -3,7 +3,6 @@
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart";
import { resolveClientMediaUrl } from "@/lib/api/media-client";
import type { CartItem } from "@/types/api";
export function CartItemRow({ item }: { item: CartItem }) {
@ -24,9 +23,9 @@ export function CartItemRow({ item }: { item: CartItem }) {
return (
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
{item.product.image && (
{item.product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolveClientMediaUrl(item.product.image) ?? undefined} alt="" className="h-full w-full object-cover" />
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-1 flex-col gap-1">

View file

@ -14,9 +14,9 @@ export function SiteHeader({
return (
<header className="flex w-full items-center justify-between px-4 py-3">
<Link href="/location/search" className="flex items-center gap-1 text-body-medium text-foreground">
<ChevronLeft className="h-4 w-4 text-gray-600" />
<span className="font-semibold">{locationLabel ?? "انتخاب محله"}</span>
<MapPin className="text-primary-action" />
<span className="font-semibold">{locationLabel ?? "انتخاب محله"}</span>
<ChevronLeft className="h-4 w-4 text-gray-600" />
</Link>
{isAuthenticated ? (

View file

@ -1,5 +1,4 @@
import Link from "next/link";
import { resolveMediaUrl } from "@/lib/api/media";
import type { StoreListItem } from "@/types/api";
/** winofyfrontenddoc (1).md §3.5 store-card. */
@ -10,9 +9,9 @@ export function StoreCard({ store }: { store: StoreListItem }) {
className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3 shadow-sm"
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-[10px] border border-gray-300 bg-muted">
{store.logo && (
{store.logo_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolveMediaUrl(store.logo) ?? undefined} alt={store.name} className="h-full w-full object-cover" />
<img src={store.logo_url} alt={store.name} className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-1 flex-col gap-1">

View file

@ -1,8 +0,0 @@
const MEDIA_ORIGIN = process.env.NEXT_PUBLIC_WINOFY_MEDIA_ORIGIN!;
/** Client-safe counterpart to lib/api/media.ts's resolveMediaUrl. */
export function resolveClientMediaUrl(path: string | null): string | null {
if (!path) return null;
if (path.startsWith("http://") || path.startsWith("https://")) return path;
return MEDIA_ORIGIN + (path.startsWith("/") ? path : `/${path}`);
}

View file

@ -1,10 +0,0 @@
import "server-only";
const API_ORIGIN = new URL(process.env.WINOFY_API_BASE_URL!).origin;
/** Store/product image fields are `null` or an absolute/relative media URL — see FRONTEND_GUIDE.md §4.5. */
export function resolveMediaUrl(path: string | null): string | null {
if (!path) return null;
if (path.startsWith("http://") || path.startsWith("https://")) return path;
return API_ORIGIN + (path.startsWith("/") ? path : `/${path}`);
}

View file

@ -1,4 +1,12 @@
/** Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman. */
/**
* Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman.
*
* Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys, not
* URLs — per FRONTEND_GUIDE.md §4.7 (feature/minio-integration), every one of them
* has a `<field>_url` sibling that's a freshly presigned, already-absolute URL.
* Always render the `_url` field directly; never resolve the raw key yourself, and
* don't cache `_url` past the response — it's re-signed (and can change) on every read.
*/
export interface City {
uuid: string;
@ -17,6 +25,7 @@ export interface StoreCategory {
uuid: string;
name: string;
icon: string | null;
icon_url: string | null;
order: number;
}
@ -25,7 +34,9 @@ export interface StoreListItem {
name: string;
category: StoreCategory;
logo: string | null;
logo_url: string | null;
cover_image: string | null;
cover_image_url: string | null;
/** DRF serializes DecimalField as a string, e.g. "3.76" — confirmed against staging. */
rating_avg: string;
rating_count: number;
@ -58,6 +69,8 @@ export type UnitType = "gram" | "ml" | "piece";
export interface ProductCategory {
uuid: string;
name: string;
icon: string | null;
icon_url: string | null;
parent: string | null;
}
@ -65,6 +78,7 @@ export interface ProductListItem {
uuid: string;
name: string;
image: string | null;
image_url: string | null;
price: number;
unit_type: UnitType;
unit_value: number;