feat: aggrigated all api calls

This commit is contained in:
Afra ‌ 2026-08-19 13:01:19 +03:30
parent 524c504b70
commit 3a075c66ec
32 changed files with 597 additions and 167 deletions

View file

@ -1,26 +1,8 @@
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { requireSession } from "@/lib/auth/session";
import type { Address, City, Neighborhood } from "@/types/api";
import { getAddresses } from "@/lib/api/addresses";
import { getCities, getNeighborhoods } from "@/lib/api/locations";
import { AddressList } from "@/components/customer/address-list";
async function getAddresses() {
const res = await winofyFetch<PaginatedResponse<Address> | Address[]>("/v1/addresses/");
return Array.isArray(res) ? res : res.results;
}
async function getCities() {
const res = await winofyFetch<PaginatedResponse<City> | City[]>("/v1/cities/", { auth: false });
return Array.isArray(res) ? res : res.results;
}
async function getNeighborhoods() {
const res = await winofyFetch<PaginatedResponse<Neighborhood> | Neighborhood[]>("/v1/neighborhoods/", {
auth: false,
params: { limit: 200 },
});
return Array.isArray(res) ? res : res.results;
}
export default async function AddressesPage() {
await requireSession("/addresses");
const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]);

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { getCart } from "@/lib/actions/cart";
import { getCart } from "@/lib/api/cart";
import { requireSession } from "@/lib/auth/session";
import { CartItemRow } from "@/components/customer/cart-item-row";
import { Button } from "@/components/ui/button";

View file

@ -1,15 +1,9 @@
import { redirect } from "next/navigation";
import { getCart } from "@/lib/actions/cart";
import { getCart } from "@/lib/api/cart";
import { requireSession } from "@/lib/auth/session";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import type { Address } from "@/types/api";
import { getAddresses } from "@/lib/api/addresses";
import { CheckoutForm } from "@/components/customer/checkout-form";
async function getAddresses() {
const res = await winofyFetch<PaginatedResponse<Address> | Address[]>("/v1/addresses/");
return Array.isArray(res) ? res : res.results;
}
export default async function CheckoutPage() {
await requireSession("/checkout");
const [cart, addresses] = await Promise.all([getCart(), getAddresses()]);

View file

@ -1,23 +1,17 @@
import { notFound } from "next/navigation";
import { winofyFetch, ApiError } from "@/lib/api/winofy";
import { ApiError } from "@/lib/api/winofy";
import { requireSession } from "@/lib/auth/session";
import { getOrderGroup } from "@/lib/api/orders";
import { OrderCard } from "@/components/customer/order-card";
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
import type { OrderGroup } from "@/types/api";
async function getOrderGroup(uuid: string) {
try {
return await winofyFetch<OrderGroup>(`/v1/order-groups/${uuid}/`);
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export default async function OrderGroupPage({ params }: PageProps<"/order-groups/[uuid]">) {
const { uuid } = await params;
await requireSession(`/order-groups/${uuid}`);
const orderGroup = await getOrderGroup(uuid);
const orderGroup = await getOrderGroup(uuid).catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
});
if (!orderGroup) notFound();
return (

View file

@ -1,13 +1,7 @@
import Link from "next/link";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { requireSession } from "@/lib/auth/session";
import { getOrderGroups } from "@/lib/api/orders";
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
import type { OrderGroup } from "@/types/api";
async function getOrderGroups() {
const res = await winofyFetch<PaginatedResponse<OrderGroup>>("/v1/order-groups/");
return res.results;
}
export default async function OrdersPage() {
await requireSession("/orders");

View file

@ -1,23 +1,17 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { winofyFetch, ApiError } from "@/lib/api/winofy";
import type { ProductDetail } from "@/types/api";
import { ApiError } from "@/lib/api/winofy";
import { getProduct } from "@/lib/api/catalog";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
const UNIT_LABELS_FA: Record<string, string> = { gram: "گرم", ml: "میلی‌لیتر", piece: "عدد" };
async function getProduct(uuid: string) {
try {
return await winofyFetch<ProductDetail>(`/v1/products/${uuid}/`, { auth: false });
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export default async function ProductDetailPage({ params }: PageProps<"/products/[uuid]">) {
const { uuid } = await params;
const product = await getProduct(uuid);
const product = await getProduct(uuid).catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
});
if (!product) notFound();
return (

View file

@ -1,34 +1,21 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy";
import type { ProductListItem, StoreDetail } from "@/types/api";
import { ApiError } from "@/lib/api/winofy";
import { getStore } from "@/lib/api/stores";
import { getProducts } from "@/lib/api/catalog";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
const WEEKDAY_LABELS_FA = ["شنبه", "یک‌شنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه"];
async function getStore(uuid: string) {
try {
return await winofyFetch<StoreDetail>(`/v1/stores/${uuid}/`, { auth: false });
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
async function getStoreProducts(uuid: string) {
const res = await winofyFetch<PaginatedResponse<ProductListItem>>("/v1/products/", {
auth: false,
params: { store: uuid, limit: 100 },
});
return res.results;
}
export default async function StoreDetailPage({ params }: PageProps<"/stores/[uuid]">) {
const { uuid } = await params;
const store = await getStore(uuid);
const store = await getStore(uuid).catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
});
if (!store) notFound();
const products = await getStoreProducts(uuid);
const products = await getProducts({ store: uuid, limit: 100 });
return (
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-6">

View file

@ -1,17 +1,8 @@
import { Suspense } from "react";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { getLocationServer } from "@/lib/location/server";
import type { Neighborhood } from "@/types/api";
import { getNeighborhoods } from "@/lib/api/locations";
import { NeighborhoodSearch } from "@/components/location/neighborhood-search";
async function getNeighborhoods() {
const res = await winofyFetch<PaginatedResponse<Neighborhood> | Neighborhood[]>("/v1/neighborhoods/", {
auth: false,
params: { limit: 200 },
});
return Array.isArray(res) ? res : res.results;
}
export default async function NeighborhoodSearchPage() {
const [neighborhoods, location] = await Promise.all([getNeighborhoods(), getLocationServer()]);
const selectedNeighborhoodUuid = location?.mode === "manual" ? location.neighborhoodUuid : null;

View file

@ -1,9 +1,8 @@
import { Suspense } from "react";
import { getValidSession } from "@/lib/auth/session";
import { getLocationServer } from "@/lib/location/server";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { getCart } from "@/lib/actions/cart";
import type { StoreCategory, StoreListItem } from "@/types/api";
import { getStoreCategories, getStores } from "@/lib/api/stores";
import { getCart } from "@/lib/api/cart";
import { SiteHeader } from "@/components/home/site-header";
import { SearchBar } from "@/components/home/search-bar";
import { LocationReminderBanner } from "@/components/home/location-reminder-banner";
@ -12,21 +11,6 @@ import { StoreCard } from "@/components/home/store-card";
import { SellerPromoCard } from "@/components/home/seller-promo-card";
import { BottomNav } from "@/components/home/bottom-nav";
async function getStoreCategories() {
const res = await winofyFetch<PaginatedResponse<StoreCategory> | StoreCategory[]>("/v1/store-categories/", {
auth: false,
});
return Array.isArray(res) ? res : res.results;
}
async function getStores(params: { category?: string; search?: string; neighborhood?: string; lat?: number; lng?: number }) {
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
auth: false,
params: { limit: 20, ...params },
});
return res.results;
}
export default async function HomePage({ searchParams }: PageProps<"/">) {
const params = await searchParams;
const category = typeof params.category === "string" ? params.category : undefined;

View file

@ -2,7 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { addToCart } from "@/lib/actions/cart";
import { addToCart } from "@/lib/api/cart";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";

View file

@ -2,7 +2,7 @@
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createAddress, deleteAddress, type AddressInput } from "@/lib/actions/addresses";
import { createAddress, deleteAddress, type AddressInput } from "@/lib/api/addresses";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Address, AddressLabel, City, Neighborhood } from "@/types/api";

View file

@ -2,7 +2,7 @@
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart";
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import type { CartItem } from "@/types/api";
export function CartItemRow({ item }: { item: CartItem }) {

View file

@ -3,7 +3,7 @@
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { submitCheckout } from "@/lib/actions/checkout";
import { submitCheckout } from "@/lib/api/checkout";
import { Button } from "@/components/ui/button";
import type { Address, Cart, DeliveryType, PaymentMethod } from "@/types/api";

View file

@ -2,7 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { cancelOrder } from "@/lib/actions/orders";
import { cancelOrder } from "@/lib/api/orders";
import { Button } from "@/components/ui/button";
import { ORDER_STATUS_LABELS_FA, ORDER_STATUS_SEQUENCE } from "@/lib/order-status";
import type { Order } from "@/types/api";

View file

@ -1,17 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch } from "@/lib/api/winofy";
import { callApi, type ActionResult } from "./result";
import type { Order } from "@/types/api";
export async function cancelOrder(uuid: string, reason: string = ""): Promise<ActionResult<Order>> {
const result = await callApi(() =>
winofyFetch<Order>(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { reason } }),
);
if (result.ok) {
revalidatePath(`/orders/${uuid}`);
revalidatePath("/orders");
}
return result;
}

View file

@ -1,10 +1,15 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch } from "@/lib/api/winofy";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Address, AddressLabel } from "@/types/api";
export async function getAddresses(): Promise<Address[]> {
const res = await winofyFetch<PaginatedResponse<Address>>("/v1/addresses/");
return res.results;
}
export interface AddressInput {
label: AddressLabel;
title: string;
@ -22,9 +27,7 @@ export interface AddressInput {
}
export async function createAddress(input: AddressInput): Promise<ActionResult<Address>> {
const result = await callApi(() =>
winofyFetch<Address>("/v1/addresses/", { method: "POST", body: input }),
);
const result = await callApi(() => winofyFetch<Address>("/v1/addresses/", { method: "POST", body: input }));
if (result.ok) revalidatePath("/addresses");
return result;
}

View file

@ -1,16 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch } from "@/lib/api/winofy";
import { winofyFetch } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Cart } from "@/types/api";
export async function getCart(): Promise<Cart> {
return winofyFetch<Cart>("/v1/cart/");
}
export async function addToCart(productUuid: string, quantity: number = 1): Promise<ActionResult> {
const result = await callApi(() =>
winofyFetch<undefined>("/v1/cart/items/", {
method: "POST",
body: { product_uuid: productUuid, quantity },
}),
winofyFetch<undefined>("/v1/cart/items/", { method: "POST", body: { product_uuid: productUuid, quantity } }),
);
if (result.ok) revalidatePath("/cart");
return result;
@ -18,10 +19,7 @@ export async function addToCart(productUuid: string, quantity: number = 1): Prom
export async function updateCartItemQuantity(itemUuid: string, quantity: number): Promise<ActionResult> {
const result = await callApi(() =>
winofyFetch<undefined>(`/v1/cart/items/${itemUuid}/`, {
method: "PATCH",
body: { quantity },
}),
winofyFetch<undefined>(`/v1/cart/items/${itemUuid}/`, { method: "PATCH", body: { quantity } }),
);
if (result.ok) revalidatePath("/cart");
return result;
@ -38,7 +36,3 @@ export async function clearCart(): Promise<ActionResult> {
if (result.ok) revalidatePath("/cart");
return result;
}
export async function getCart(): Promise<Cart> {
return winofyFetch<Cart>("/v1/cart/");
}

30
src/lib/api/catalog.ts Normal file
View file

@ -0,0 +1,30 @@
import "server-only";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import type { ProductCategory, ProductDetail, ProductListItem } from "@/types/api";
export async function getProductCategories(): Promise<ProductCategory[]> {
const res = await winofyFetch<PaginatedResponse<ProductCategory>>("/v1/product-categories/", {
auth: false,
params: { limit: 100 },
});
return res.results;
}
export interface GetProductsParams {
store?: string;
category?: string;
search?: string;
limit?: number;
}
export async function getProducts(params: GetProductsParams = {}): Promise<ProductListItem[]> {
const res = await winofyFetch<PaginatedResponse<ProductListItem>>("/v1/products/", {
auth: false,
params: { limit: 100, ...params },
});
return res.results;
}
export async function getProduct(uuid: string): Promise<ProductDetail> {
return winofyFetch<ProductDetail>(`/v1/products/${uuid}/`, { auth: false });
}

View file

@ -1,6 +1,6 @@
"use server";
import { winofyFetch } from "@/lib/api/winofy";
import { winofyFetch } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { DeliveryType, OrderGroup, PaymentMethod } from "@/types/api";

24
src/lib/api/locations.ts Normal file
View file

@ -0,0 +1,24 @@
import "server-only";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import type { City, Neighborhood } from "@/types/api";
export async function getCities(): Promise<City[]> {
const res = await winofyFetch<PaginatedResponse<City>>("/v1/cities/", { auth: false, params: { limit: 100 } });
return res.results;
}
export async function getCity(uuid: string): Promise<City> {
return winofyFetch<City>(`/v1/cities/${uuid}/`, { auth: false });
}
export async function getNeighborhoods(params: { city?: string; limit?: number } = {}): Promise<Neighborhood[]> {
const res = await winofyFetch<PaginatedResponse<Neighborhood>>("/v1/neighborhoods/", {
auth: false,
params: { limit: 200, ...params },
});
return res.results;
}
export async function getNeighborhood(uuid: string): Promise<Neighborhood> {
return winofyFetch<Neighborhood>(`/v1/neighborhoods/${uuid}/`, { auth: false });
}

22
src/lib/api/media.ts Normal file
View file

@ -0,0 +1,22 @@
"use server";
import { winofyFetch } from "./winofy";
import { callApi, type ActionResult } from "./result";
export interface MediaPresignResult {
upload_url: string;
object_key: string;
}
/**
* Step 1 of the upload flow (§2 of the API guide): get a presigned MinIO PUT
* URL. Step 2 — PUT the raw file bytes to `upload_url` directly (client-side,
* no auth header, this is a MinIO URL not a Winofy one) — happens in the
* browser, not here. Step 3 — send the returned `object_key` as the value of
* whatever field you're setting (Store.logo, Product.image, etc.).
*/
export async function presignUpload(filename: string): Promise<ActionResult<MediaPresignResult>> {
return callApi(() =>
winofyFetch<MediaPresignResult>("/media/presign/", { method: "POST", body: { filename } }),
);
}

View file

@ -0,0 +1,31 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Notification } from "@/types/api";
export async function getNotifications(): Promise<Notification[]> {
const res = await winofyFetch<PaginatedResponse<Notification>>("/v1/notifications/");
return res.results;
}
export async function getNotification(uuid: string): Promise<Notification> {
return winofyFetch<Notification>(`/v1/notifications/${uuid}/`);
}
export async function markNotificationRead(uuid: string): Promise<ActionResult<Notification>> {
const result = await callApi(() =>
winofyFetch<Notification>(`/v1/notifications/${uuid}/mark-read/`, { method: "POST", body: {} }),
);
if (result.ok) revalidatePath("/notifications");
return result;
}
export async function markAllNotificationsRead(): Promise<ActionResult> {
const result = await callApi(() =>
winofyFetch<undefined>("/v1/notifications/mark-all-read/", { method: "POST", body: {} }),
);
if (result.ok) revalidatePath("/notifications");
return result;
}

28
src/lib/api/orders.ts Normal file
View file

@ -0,0 +1,28 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Order, OrderGroup } from "@/types/api";
export async function getOrderGroups(): Promise<OrderGroup[]> {
const res = await winofyFetch<PaginatedResponse<OrderGroup>>("/v1/order-groups/");
return res.results;
}
export async function getOrderGroup(uuid: string): Promise<OrderGroup> {
return winofyFetch<OrderGroup>(`/v1/order-groups/${uuid}/`);
}
/** Single-order tracking — one store's slice of an OrderGroup. */
export async function getOrder(uuid: string): Promise<Order> {
return winofyFetch<Order>(`/v1/orders/${uuid}/`);
}
export async function cancelOrder(uuid: string, cancelReason: string = ""): Promise<ActionResult<Order>> {
const result = await callApi(() =>
winofyFetch<Order>(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { cancel_reason: cancelReason } }),
);
if (result.ok) revalidatePath("/orders");
return result;
}

View file

@ -1,10 +1,11 @@
import "server-only";
import { ApiError } from "@/lib/api/winofy";
import { ApiError } from "./winofy";
export type ActionResult<T = undefined> =
| { ok: true; data: T }
| { ok: false; status: number; message: string; fieldErrors: Record<string, string[]> | null };
/** Wraps a winofyFetch call for use in a Server Action — never throws, always returns a typed result. */
export async function callApi<T>(fn: () => Promise<T>): Promise<ActionResult<T>> {
try {
const data = await fn();

23
src/lib/api/reviews.ts Normal file
View file

@ -0,0 +1,23 @@
"use server";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Review } from "@/types/api";
export async function getReviews(params: { store?: string; limit?: number } = {}): Promise<Review[]> {
const res = await winofyFetch<PaginatedResponse<Review>>("/v1/reviews/", {
auth: false,
params: { limit: 50, ...params },
});
return res.results;
}
export interface CreateReviewInput {
order_uuid: string;
rating: number;
comment: string;
}
export async function createReview(input: CreateReviewInput): Promise<ActionResult<Review>> {
return callApi(() => winofyFetch<Review>("/v1/reviews/", { method: "POST", body: input }));
}

View file

@ -0,0 +1,8 @@
import "server-only";
import { winofyFetch } from "../winofy";
import type { SellerAnalytics } from "@/types/api";
/** No date-range params are documented — the API returns one fixed reporting window. */
export async function getSellerAnalytics(): Promise<SellerAnalytics> {
return winofyFetch<SellerAnalytics>("/v1/seller/analytics/");
}

View file

@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { Order, OrderStatus } from "@/types/api";
export async function getSellerOrders(status?: OrderStatus): Promise<Order[]> {
const res = await winofyFetch<PaginatedResponse<Order>>("/v1/seller/orders/", {
params: { limit: 100, status },
});
return res.results;
}
export async function getSellerOrder(uuid: string): Promise<Order> {
return winofyFetch<Order>(`/v1/seller/orders/${uuid}/`);
}
async function sellerOrderAction(
uuid: string,
action: string,
body: Record<string, unknown> = {},
): Promise<ActionResult<Order>> {
const result = await callApi(() =>
winofyFetch<Order>(`/v1/seller/orders/${uuid}/${action}/`, { method: "POST", body }),
);
if (result.ok) {
revalidatePath("/seller/orders");
revalidatePath(`/seller/orders/${uuid}`);
}
return result;
}
export async function cancelSellerOrder(uuid: string, cancelReason: string = ""): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "cancel", { cancel_reason: cancelReason });
}
/** placed → preparing */
export async function confirmSellerOrder(uuid: string): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "confirm");
}
/** preparing → ready_to_ship */
export async function markSellerOrderReady(uuid: string): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "mark-ready");
}
/** ready_to_ship → handed_to_courier */
export async function markSellerOrderShipped(
uuid: string,
courierName: string,
courierPhone: string,
): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "mark-shipped", { courier_name: courierName, courier_phone: courierPhone });
}
/** handed_to_courier → delivered */
export async function markSellerOrderDelivered(uuid: string): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "mark-delivered");
}

View file

@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { SellerProduct } from "@/types/api";
export async function getSellerProducts(): Promise<SellerProduct[]> {
const res = await winofyFetch<PaginatedResponse<SellerProduct>>("/v1/seller/products/", { params: { limit: 100 } });
return res.results;
}
export async function getSellerProduct(uuid: string): Promise<SellerProduct> {
return winofyFetch<SellerProduct>(`/v1/seller/products/${uuid}/`);
}
export interface SellerProductInput {
name: string;
description: string;
image?: string | null;
category_uuid: string;
price: number;
unit_type: SellerProduct["unit_type"];
unit_value: number;
stock_quantity: number;
low_stock_threshold: number;
is_active: boolean;
}
export async function createSellerProduct(input: SellerProductInput): Promise<ActionResult<SellerProduct>> {
const result = await callApi(() =>
winofyFetch<SellerProduct>("/v1/seller/products/", { method: "POST", body: input }),
);
if (result.ok) revalidatePath("/seller/products");
return result;
}
export async function updateSellerProduct(
uuid: string,
input: Partial<SellerProductInput>,
): Promise<ActionResult<SellerProduct>> {
const result = await callApi(() =>
winofyFetch<SellerProduct>(`/v1/seller/products/${uuid}/`, { method: "PATCH", body: input }),
);
if (result.ok) revalidatePath("/seller/products");
return result;
}
export async function deleteSellerProduct(uuid: string): Promise<ActionResult> {
const result = await callApi(() => winofyFetch<undefined>(`/v1/seller/products/${uuid}/`, { method: "DELETE" }));
if (result.ok) revalidatePath("/seller/products");
return result;
}
/** Focused inventory-only update — returns 204, no body, unlike the general PATCH above. */
export async function updateSellerProductStock(
uuid: string,
input: { stock_quantity: number; low_stock_threshold?: number },
): Promise<ActionResult> {
const result = await callApi(() =>
winofyFetch<undefined>(`/v1/seller/products/${uuid}/stock/`, { method: "PATCH", body: input }),
);
if (result.ok) revalidatePath("/seller/products");
return result;
}

View file

@ -0,0 +1,26 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { Review } from "@/types/api";
export async function getSellerReviews(): Promise<Review[]> {
const res = await winofyFetch<PaginatedResponse<Review>>("/v1/seller/reviews/", { params: { limit: 100 } });
return res.results;
}
/**
* ⚠️ The spec documents this action's request body as the full customer-facing
* review-write shape (order_uuid/rating/comment), which doesn't make sense for
* a reply. Sending `seller_reply` here as the best guess (matches the response
* field name) — confirm the real field name with backend (or from a 400's
* field-error keys) before relying on this.
*/
export async function replySellerReview(uuid: string, replyText: string): Promise<ActionResult<Review>> {
const result = await callApi(() =>
winofyFetch<Review>(`/v1/seller/reviews/${uuid}/reply/`, { method: "POST", body: { seller_reply: replyText } }),
);
if (result.ok) revalidatePath("/seller/reviews");
return result;
}

View file

@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, ApiError } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { SellerStore, WorkingHours } from "@/types/api";
/** `null` means the seller hasn't created a store yet (404) — not an error state to surface. */
export async function getSellerStore(): Promise<SellerStore | null> {
try {
return await winofyFetch<SellerStore>("/v1/seller/store/");
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export interface SellerStoreInput {
name: string;
category_uuid: string;
description: string;
logo?: string | null;
cover_image?: string | null;
phone_number: string;
city_uuid: string;
address: string;
latitude: number;
longitude: number;
service_neighborhood_uuids: string[];
delivery_radius_km: string;
min_order_amount: number;
delivery_fee: number;
free_delivery_threshold?: number | null;
accepts_wallet: boolean;
accepts_online: boolean;
accepts_cash_on_delivery: boolean;
is_open: boolean;
}
export async function createSellerStore(input: SellerStoreInput): Promise<ActionResult<SellerStore>> {
const result = await callApi(() => winofyFetch<SellerStore>("/v1/seller/store/", { method: "POST", body: input }));
if (result.ok) revalidatePath("/seller");
return result;
}
export async function updateSellerStore(input: Partial<SellerStoreInput>): Promise<ActionResult<SellerStore>> {
const result = await callApi(() =>
winofyFetch<SellerStore>("/v1/seller/store/", { method: "PATCH", body: input }),
);
if (result.ok) revalidatePath("/seller");
return result;
}
export async function getSellerWorkingHours(): Promise<WorkingHours[]> {
return winofyFetch<WorkingHours[]>("/v1/seller/store/working-hours/");
}
/** Full replace — send all 7 days (weekday 0–6) in one call. */
export async function setSellerWorkingHours(hours: WorkingHours[]): Promise<ActionResult<WorkingHours[]>> {
const result = await callApi(() =>
winofyFetch<WorkingHours[]>("/v1/seller/store/working-hours/", { method: "PUT", body: hours }),
);
if (result.ok) revalidatePath("/seller");
return result;
}

32
src/lib/api/stores.ts Normal file
View file

@ -0,0 +1,32 @@
import "server-only";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import type { StoreCategory, StoreDetail, StoreListItem } from "@/types/api";
export async function getStoreCategories(): Promise<StoreCategory[]> {
const res = await winofyFetch<PaginatedResponse<StoreCategory>>("/v1/store-categories/", {
auth: false,
params: { limit: 100 },
});
return res.results;
}
export interface GetStoresParams {
category?: string;
search?: string;
neighborhood?: string;
lat?: number;
lng?: number;
limit?: number;
}
export async function getStores(params: GetStoresParams = {}): Promise<StoreListItem[]> {
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
auth: false,
params: { limit: 20, ...params },
});
return res.results;
}
export async function getStore(uuid: string): Promise<StoreDetail> {
return winofyFetch<StoreDetail>(`/v1/stores/${uuid}/`, { auth: false });
}

View file

@ -1,13 +1,17 @@
/**
* Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman.
* Shared API types, mirrored from winofyapifrontendintegrationguide.md + the
* OpenAPI spec (source of truth for field names/shapes). Money fields are
* integer Toman. Grouped to match src/lib/api/'s feature layout.
*
* 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.
* Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys,
* not URLs — every one 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.
*/
// ---- Locations --------------------------------------------------------------
export interface City {
uuid: string;
name: string;
@ -17,10 +21,13 @@ export interface City {
export interface Neighborhood {
uuid: string;
name: string;
slug?: string;
/** Nested object, confirmed against staging — not a bare uuid string. */
city: City;
}
// ---- Stores / Catalog ---------------------------------------------------
export interface StoreCategory {
uuid: string;
name: string;
@ -46,11 +53,10 @@ export interface StoreListItem {
is_open: boolean;
}
export interface WorkingHours {
weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Saturday .. 6 = Friday
opens_at: string;
closes_at: string;
opens_at: string | null;
closes_at: string | null;
is_closed: boolean;
}
@ -92,6 +98,8 @@ export interface ProductDetail extends ProductListItem {
store: StoreListItem;
}
// ---- Addresses ----------------------------------------------------------
export type AddressLabel = "home" | "work" | "other";
export interface Address {
@ -112,6 +120,8 @@ export interface Address {
created_at: string;
}
// ---- Cart -----------------------------------------------------------------
export interface CartItem {
uuid: string;
product: ProductListItem;
@ -133,20 +143,16 @@ export interface Cart {
grand_total: number;
}
// ---- Checkout / Orders ---------------------------------------------------
export type DeliveryType = "express" | "scheduled";
export type PaymentMethod = "wallet" | "online" | "cash_on_delivery";
export type PaymentStatus = "pending" | "paid" | "failed";
export type OrderStatus =
| "placed"
| "preparing"
| "ready_to_ship"
| "handed_to_courier"
| "delivered"
| "cancelled";
export type OrderStatus = "placed" | "preparing" | "ready_to_ship" | "handed_to_courier" | "delivered" | "cancelled";
export interface OrderItem {
uuid: string;
product: string;
product: string | null;
product_name_snapshot: string;
unit_price_snapshot: number;
quantity: number;
@ -195,3 +201,112 @@ export interface OrderGroup {
/** `feature/payment` only. */
payment?: { method: PaymentMethod; status: string; payment_url?: string };
}
// ---- Reviews --------------------------------------------------------------
export interface Review {
uuid: string;
store: string;
product: string | null;
rating: number;
comment: string;
seller_reply: string | null;
customer_name: string;
created_at: string;
}
// ---- Notifications --------------------------------------------------------
export type NotificationType = "new_order" | "settlement_done" | "new_review" | "order_cancelled";
export interface Notification {
uuid: string;
type: NotificationType;
title: string;
body: string;
related_order: string | null;
is_read: boolean;
created_at: string;
}
// ---- Seller · Store ---------------------------------------------------
export type SellerStoreStatus = "pending" | "approved" | "suspended";
export interface StoreBankAccount {
iban: string;
account_holder_name: string;
}
export interface SellerStore {
uuid: string;
name: string;
category: StoreCategory;
description: string;
logo: string | null;
logo_url: string | null;
cover_image: string | null;
cover_image_url: string | null;
phone_number: string;
city: City;
address: string;
service_neighborhoods: Neighborhood[];
/** DRF decimal-as-string, e.g. "5.00". */
delivery_radius_km: string;
min_order_amount: number;
delivery_fee: number;
free_delivery_threshold: number | null;
accepts_wallet: boolean;
accepts_online: boolean;
accepts_cash_on_delivery: boolean;
rating_avg: string;
rating_count: number;
status: SellerStoreStatus;
is_open: boolean;
bank_account: StoreBankAccount | null;
created_at: string;
}
// ---- Seller · Products ---------------------------------------------------
export interface SellerProduct {
uuid: string;
name: string;
description: string;
image: string | null;
image_url: string | null;
category: ProductCategory;
price: number;
unit_type: UnitType;
unit_value: number;
stock_quantity: number;
low_stock_threshold: number;
is_active: boolean;
sold_count: number;
is_out_of_stock: boolean;
is_low_stock: boolean;
created_at: string;
}
// ---- Seller · Analytics ---------------------------------------------------
export interface SalesChartPoint {
bucket: string;
sales: number;
}
export interface TopProduct {
name: string;
units_sold: number;
revenue: number;
}
export interface SellerAnalytics {
period: string;
order_count: number;
total_sales: number;
cancellation_rate: number;
average_order_value: number;
chart: SalesChartPoint[];
top_products: TopProduct[];
}