diff --git a/src/app/(customer)/(shop)/addresses/page.tsx b/src/app/(customer)/(shop)/addresses/page.tsx index 505294d..ae25141 100644 --- a/src/app/(customer)/(shop)/addresses/page.tsx +++ b/src/app/(customer)/(shop)/addresses/page.tsx @@ -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 | Address[]>("/v1/addresses/"); - return Array.isArray(res) ? res : res.results; -} - -async function getCities() { - const res = await winofyFetch | City[]>("/v1/cities/", { auth: false }); - return Array.isArray(res) ? res : res.results; -} - -async function getNeighborhoods() { - const res = await winofyFetch | 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()]); diff --git a/src/app/(customer)/(shop)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx index 331d8ec..31bb900 100644 --- a/src/app/(customer)/(shop)/cart/page.tsx +++ b/src/app/(customer)/(shop)/cart/page.tsx @@ -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"; diff --git a/src/app/(customer)/(shop)/checkout/page.tsx b/src/app/(customer)/(shop)/checkout/page.tsx index d60797f..2182a1b 100644 --- a/src/app/(customer)/(shop)/checkout/page.tsx +++ b/src/app/(customer)/(shop)/checkout/page.tsx @@ -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 | 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()]); diff --git a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx index 5563862..5312d7d 100644 --- a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx @@ -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(`/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 ( diff --git a/src/app/(customer)/(shop)/orders/page.tsx b/src/app/(customer)/(shop)/orders/page.tsx index f20fc51..ecdd790 100644 --- a/src/app/(customer)/(shop)/orders/page.tsx +++ b/src/app/(customer)/(shop)/orders/page.tsx @@ -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>("/v1/order-groups/"); - return res.results; -} export default async function OrdersPage() { await requireSession("/orders"); diff --git a/src/app/(customer)/(shop)/products/[uuid]/page.tsx b/src/app/(customer)/(shop)/products/[uuid]/page.tsx index 1c5927b..c9ae6ae 100644 --- a/src/app/(customer)/(shop)/products/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/products/[uuid]/page.tsx @@ -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 = { gram: "گرم", ml: "میلی‌لیتر", piece: "عدد" }; -async function getProduct(uuid: string) { - try { - return await winofyFetch(`/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 ( diff --git a/src/app/(customer)/(shop)/stores/[uuid]/page.tsx b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx index c0aa9f7..7962f98 100644 --- a/src/app/(customer)/(shop)/stores/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx @@ -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(`/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>("/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 (
diff --git a/src/app/(customer)/location/search/page.tsx b/src/app/(customer)/location/search/page.tsx index fcb5d8b..c2bc65c 100644 --- a/src/app/(customer)/location/search/page.tsx +++ b/src/app/(customer)/location/search/page.tsx @@ -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 | 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; diff --git a/src/app/(customer)/page.tsx b/src/app/(customer)/page.tsx index 99b0ec8..1227e1c 100644 --- a/src/app/(customer)/page.tsx +++ b/src/app/(customer)/page.tsx @@ -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 | 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>("/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; diff --git a/src/components/customer/add-to-cart-button.tsx b/src/components/customer/add-to-cart-button.tsx index cfc6354..f860672 100644 --- a/src/components/customer/add-to-cart-button.tsx +++ b/src/components/customer/add-to-cart-button.tsx @@ -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"; diff --git a/src/components/customer/address-list.tsx b/src/components/customer/address-list.tsx index badbda8..c80af60 100644 --- a/src/components/customer/address-list.tsx +++ b/src/components/customer/address-list.tsx @@ -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"; diff --git a/src/components/customer/cart-item-row.tsx b/src/components/customer/cart-item-row.tsx index 87fb5fd..923824b 100644 --- a/src/components/customer/cart-item-row.tsx +++ b/src/components/customer/cart-item-row.tsx @@ -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 }) { diff --git a/src/components/customer/checkout-form.tsx b/src/components/customer/checkout-form.tsx index 9468bd7..a8af62d 100644 --- a/src/components/customer/checkout-form.tsx +++ b/src/components/customer/checkout-form.tsx @@ -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"; diff --git a/src/components/customer/order-card.tsx b/src/components/customer/order-card.tsx index 9e1f482..c8371f4 100644 --- a/src/components/customer/order-card.tsx +++ b/src/components/customer/order-card.tsx @@ -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"; diff --git a/src/lib/actions/orders.ts b/src/lib/actions/orders.ts deleted file mode 100644 index 8325f69..0000000 --- a/src/lib/actions/orders.ts +++ /dev/null @@ -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> { - const result = await callApi(() => - winofyFetch(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { reason } }), - ); - if (result.ok) { - revalidatePath(`/orders/${uuid}`); - revalidatePath("/orders"); - } - return result; -} diff --git a/src/lib/actions/addresses.ts b/src/lib/api/addresses.ts similarity index 78% rename from src/lib/actions/addresses.ts rename to src/lib/api/addresses.ts index 55324bb..e2c748a 100644 --- a/src/lib/actions/addresses.ts +++ b/src/lib/api/addresses.ts @@ -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 { + const res = await winofyFetch>("/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> { - const result = await callApi(() => - winofyFetch
("/v1/addresses/", { method: "POST", body: input }), - ); + const result = await callApi(() => winofyFetch
("/v1/addresses/", { method: "POST", body: input })); if (result.ok) revalidatePath("/addresses"); return result; } diff --git a/src/lib/actions/cart.ts b/src/lib/api/cart.ts similarity index 79% rename from src/lib/actions/cart.ts rename to src/lib/api/cart.ts index e013ba3..d26e630 100644 --- a/src/lib/actions/cart.ts +++ b/src/lib/api/cart.ts @@ -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 { + return winofyFetch("/v1/cart/"); +} + export async function addToCart(productUuid: string, quantity: number = 1): Promise { const result = await callApi(() => - winofyFetch("/v1/cart/items/", { - method: "POST", - body: { product_uuid: productUuid, quantity }, - }), + winofyFetch("/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 { const result = await callApi(() => - winofyFetch(`/v1/cart/items/${itemUuid}/`, { - method: "PATCH", - body: { quantity }, - }), + winofyFetch(`/v1/cart/items/${itemUuid}/`, { method: "PATCH", body: { quantity } }), ); if (result.ok) revalidatePath("/cart"); return result; @@ -38,7 +36,3 @@ export async function clearCart(): Promise { if (result.ok) revalidatePath("/cart"); return result; } - -export async function getCart(): Promise { - return winofyFetch("/v1/cart/"); -} diff --git a/src/lib/api/catalog.ts b/src/lib/api/catalog.ts new file mode 100644 index 0000000..08fd304 --- /dev/null +++ b/src/lib/api/catalog.ts @@ -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 { + const res = await winofyFetch>("/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 { + const res = await winofyFetch>("/v1/products/", { + auth: false, + params: { limit: 100, ...params }, + }); + return res.results; +} + +export async function getProduct(uuid: string): Promise { + return winofyFetch(`/v1/products/${uuid}/`, { auth: false }); +} diff --git a/src/lib/actions/checkout.ts b/src/lib/api/checkout.ts similarity index 91% rename from src/lib/actions/checkout.ts rename to src/lib/api/checkout.ts index c876497..dc10ce5 100644 --- a/src/lib/actions/checkout.ts +++ b/src/lib/api/checkout.ts @@ -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"; diff --git a/src/lib/api/locations.ts b/src/lib/api/locations.ts new file mode 100644 index 0000000..0697884 --- /dev/null +++ b/src/lib/api/locations.ts @@ -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 { + const res = await winofyFetch>("/v1/cities/", { auth: false, params: { limit: 100 } }); + return res.results; +} + +export async function getCity(uuid: string): Promise { + return winofyFetch(`/v1/cities/${uuid}/`, { auth: false }); +} + +export async function getNeighborhoods(params: { city?: string; limit?: number } = {}): Promise { + const res = await winofyFetch>("/v1/neighborhoods/", { + auth: false, + params: { limit: 200, ...params }, + }); + return res.results; +} + +export async function getNeighborhood(uuid: string): Promise { + return winofyFetch(`/v1/neighborhoods/${uuid}/`, { auth: false }); +} diff --git a/src/lib/api/media.ts b/src/lib/api/media.ts new file mode 100644 index 0000000..8463cbf --- /dev/null +++ b/src/lib/api/media.ts @@ -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> { + return callApi(() => + winofyFetch("/media/presign/", { method: "POST", body: { filename } }), + ); +} diff --git a/src/lib/api/notifications.ts b/src/lib/api/notifications.ts new file mode 100644 index 0000000..46a6f34 --- /dev/null +++ b/src/lib/api/notifications.ts @@ -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 { + const res = await winofyFetch>("/v1/notifications/"); + return res.results; +} + +export async function getNotification(uuid: string): Promise { + return winofyFetch(`/v1/notifications/${uuid}/`); +} + +export async function markNotificationRead(uuid: string): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/notifications/${uuid}/mark-read/`, { method: "POST", body: {} }), + ); + if (result.ok) revalidatePath("/notifications"); + return result; +} + +export async function markAllNotificationsRead(): Promise { + const result = await callApi(() => + winofyFetch("/v1/notifications/mark-all-read/", { method: "POST", body: {} }), + ); + if (result.ok) revalidatePath("/notifications"); + return result; +} diff --git a/src/lib/api/orders.ts b/src/lib/api/orders.ts new file mode 100644 index 0000000..1a4cb78 --- /dev/null +++ b/src/lib/api/orders.ts @@ -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 { + const res = await winofyFetch>("/v1/order-groups/"); + return res.results; +} + +export async function getOrderGroup(uuid: string): Promise { + return winofyFetch(`/v1/order-groups/${uuid}/`); +} + +/** Single-order tracking — one store's slice of an OrderGroup. */ +export async function getOrder(uuid: string): Promise { + return winofyFetch(`/v1/orders/${uuid}/`); +} + +export async function cancelOrder(uuid: string, cancelReason: string = ""): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { cancel_reason: cancelReason } }), + ); + if (result.ok) revalidatePath("/orders"); + return result; +} diff --git a/src/lib/actions/result.ts b/src/lib/api/result.ts similarity index 80% rename from src/lib/actions/result.ts rename to src/lib/api/result.ts index f256d0a..1002771 100644 --- a/src/lib/actions/result.ts +++ b/src/lib/api/result.ts @@ -1,10 +1,11 @@ import "server-only"; -import { ApiError } from "@/lib/api/winofy"; +import { ApiError } from "./winofy"; export type ActionResult = | { ok: true; data: T } | { ok: false; status: number; message: string; fieldErrors: Record | null }; +/** Wraps a winofyFetch call for use in a Server Action — never throws, always returns a typed result. */ export async function callApi(fn: () => Promise): Promise> { try { const data = await fn(); diff --git a/src/lib/api/reviews.ts b/src/lib/api/reviews.ts new file mode 100644 index 0000000..1347873 --- /dev/null +++ b/src/lib/api/reviews.ts @@ -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 { + const res = await winofyFetch>("/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> { + return callApi(() => winofyFetch("/v1/reviews/", { method: "POST", body: input })); +} diff --git a/src/lib/api/seller/analytics.ts b/src/lib/api/seller/analytics.ts new file mode 100644 index 0000000..6800268 --- /dev/null +++ b/src/lib/api/seller/analytics.ts @@ -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 { + return winofyFetch("/v1/seller/analytics/"); +} diff --git a/src/lib/api/seller/orders.ts b/src/lib/api/seller/orders.ts new file mode 100644 index 0000000..723fedf --- /dev/null +++ b/src/lib/api/seller/orders.ts @@ -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 { + const res = await winofyFetch>("/v1/seller/orders/", { + params: { limit: 100, status }, + }); + return res.results; +} + +export async function getSellerOrder(uuid: string): Promise { + return winofyFetch(`/v1/seller/orders/${uuid}/`); +} + +async function sellerOrderAction( + uuid: string, + action: string, + body: Record = {}, +): Promise> { + const result = await callApi(() => + winofyFetch(`/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> { + return sellerOrderAction(uuid, "cancel", { cancel_reason: cancelReason }); +} + +/** placed → preparing */ +export async function confirmSellerOrder(uuid: string): Promise> { + return sellerOrderAction(uuid, "confirm"); +} + +/** preparing → ready_to_ship */ +export async function markSellerOrderReady(uuid: string): Promise> { + return sellerOrderAction(uuid, "mark-ready"); +} + +/** ready_to_ship → handed_to_courier */ +export async function markSellerOrderShipped( + uuid: string, + courierName: string, + courierPhone: string, +): Promise> { + return sellerOrderAction(uuid, "mark-shipped", { courier_name: courierName, courier_phone: courierPhone }); +} + +/** handed_to_courier → delivered */ +export async function markSellerOrderDelivered(uuid: string): Promise> { + return sellerOrderAction(uuid, "mark-delivered"); +} diff --git a/src/lib/api/seller/products.ts b/src/lib/api/seller/products.ts new file mode 100644 index 0000000..c3b3d56 --- /dev/null +++ b/src/lib/api/seller/products.ts @@ -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 { + const res = await winofyFetch>("/v1/seller/products/", { params: { limit: 100 } }); + return res.results; +} + +export async function getSellerProduct(uuid: string): Promise { + return winofyFetch(`/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> { + const result = await callApi(() => + winofyFetch("/v1/seller/products/", { method: "POST", body: input }), + ); + if (result.ok) revalidatePath("/seller/products"); + return result; +} + +export async function updateSellerProduct( + uuid: string, + input: Partial, +): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/seller/products/${uuid}/`, { method: "PATCH", body: input }), + ); + if (result.ok) revalidatePath("/seller/products"); + return result; +} + +export async function deleteSellerProduct(uuid: string): Promise { + const result = await callApi(() => winofyFetch(`/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 { + const result = await callApi(() => + winofyFetch(`/v1/seller/products/${uuid}/stock/`, { method: "PATCH", body: input }), + ); + if (result.ok) revalidatePath("/seller/products"); + return result; +} diff --git a/src/lib/api/seller/reviews.ts b/src/lib/api/seller/reviews.ts new file mode 100644 index 0000000..0d28100 --- /dev/null +++ b/src/lib/api/seller/reviews.ts @@ -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 { + const res = await winofyFetch>("/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> { + const result = await callApi(() => + winofyFetch(`/v1/seller/reviews/${uuid}/reply/`, { method: "POST", body: { seller_reply: replyText } }), + ); + if (result.ok) revalidatePath("/seller/reviews"); + return result; +} diff --git a/src/lib/api/seller/store.ts b/src/lib/api/seller/store.ts new file mode 100644 index 0000000..13042d5 --- /dev/null +++ b/src/lib/api/seller/store.ts @@ -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 { + try { + return await winofyFetch("/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> { + const result = await callApi(() => winofyFetch("/v1/seller/store/", { method: "POST", body: input })); + if (result.ok) revalidatePath("/seller"); + return result; +} + +export async function updateSellerStore(input: Partial): Promise> { + const result = await callApi(() => + winofyFetch("/v1/seller/store/", { method: "PATCH", body: input }), + ); + if (result.ok) revalidatePath("/seller"); + return result; +} + +export async function getSellerWorkingHours(): Promise { + return winofyFetch("/v1/seller/store/working-hours/"); +} + +/** Full replace — send all 7 days (weekday 0–6) in one call. */ +export async function setSellerWorkingHours(hours: WorkingHours[]): Promise> { + const result = await callApi(() => + winofyFetch("/v1/seller/store/working-hours/", { method: "PUT", body: hours }), + ); + if (result.ok) revalidatePath("/seller"); + return result; +} diff --git a/src/lib/api/stores.ts b/src/lib/api/stores.ts new file mode 100644 index 0000000..2b0d38e --- /dev/null +++ b/src/lib/api/stores.ts @@ -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 { + const res = await winofyFetch>("/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 { + const res = await winofyFetch>("/v1/stores/", { + auth: false, + params: { limit: 20, ...params }, + }); + return res.results; +} + +export async function getStore(uuid: string): Promise { + return winofyFetch(`/v1/stores/${uuid}/`, { auth: false }); +} diff --git a/src/types/api.ts b/src/types/api.ts index fc80d4d..c0c86e3 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 `_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 `_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[]; +}