This commit is contained in:
NotII
2025-03-24 01:46:11 +00:00
parent 1e395b8684
commit 39c349509c
19 changed files with 477 additions and 427 deletions

View File

@@ -11,5 +11,4 @@ NEXT_LOG_LEVEL=error
GENERATE_SOURCEMAP=false
# API configuration
NEXT_PUBLIC_API_URL=/api
SERVER_API_URL=https://internal-api.inboxi.ng/api

View File

@@ -1,18 +1,13 @@
# Use official Node.js image as base
FROM node:18-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm install --force
# Copy source code
COPY . .
# Set environment variables for build
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_URL=/api
ENV SERVER_API_URL=https://internal-api.inboxi.ng/api
# Build the Next.js application
RUN npm run build
@@ -23,22 +18,19 @@ FROM node:18-alpine AS runner
# Set working directory inside the container
WORKDIR /app
# Create necessary directories
RUN mkdir -p /app/public
# Copy only necessary files from builder
COPY --from=builder /app/package.json /app/package-lock.json ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/public ./public
# Expose the app port
EXPOSE 3000
# Set runtime environment variables
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_URL=/api
ENV SERVER_API_URL=https://internal-api.inboxi.ng/api
# Start Next.js server
CMD ["npm", "run", "start"]

99
app/actions.ts Normal file
View File

@@ -0,0 +1,99 @@
"use server";
/**
* NOTE: This file contains workarounds for missing backend endpoints.
* In the future, consider implementing proper API endpoints for these functions:
* - GET /chats/:chatId/members - To get chat members
* - GET /users/:userId - To get user information
*/
/**
* Helper function to make a server-side fetch with proper error handling
*/
async function safeFetch(path: string) {
try {
// Use absolute URL with the API_URL from environment variable or default to local
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
// Remove any leading /api/ or api/ to prevent double prefixing
let cleanPath = path.replace(/^\/?(api\/)+/, '');
// Ensure path starts with a / for consistent joining
if (!cleanPath.startsWith('/')) {
cleanPath = '/' + cleanPath;
}
// Construct the URL with a single /api prefix
const url = `${baseUrl}/api${cleanPath}`;
console.log(`Server action fetching URL: ${url}`);
// For server components, use the fetch API directly
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching ${path}:`, error);
return null;
}
}
/**
* Get chat details and extract members information
*/
export async function getChatMembers(chatId: string) {
try {
// Use the safeFetch helper with the correct path - avoid any api prefix
const response = await safeFetch(`chats/${chatId}`);
if (response) {
// Create member objects for buyer and vendor
const members = [
{
_id: response.buyerId,
isVendor: false,
telegramUsername: response.telegramUsername || null
},
{
_id: response.vendorId,
isVendor: true
}
];
return { members };
}
return { members: [] };
} catch (error) {
console.error("Error in getChatMembers:", error);
return { members: [] };
}
}
/**
* Get user information by ID
* This function is imported but doesn't appear to be used in the component.
* Keeping a placeholder implementation for future use.
*/
export async function getUserInfoById(userId: string) {
try {
// This function isn't actually used currently, but we'll keep a
// placeholder implementation in case it's needed in the future
return {
user: {
_id: userId,
name: userId && userId.length > 8
? `Customer ${userId.slice(-4)}` // For buyers
: "Vendor", // For vendors
isVendor: userId && userId.length <= 8
}
};
} catch (error) {
console.error("Error in getUserInfoById:", error);
return { user: null };
}
}

View File

@@ -1,17 +1,17 @@
"use client";
import React from "react";
import { useParams } from "next/navigation";
import { Metadata } from "next";
import ChatDetail from "@/components/dashboard/ChatDetail";
import Dashboard from "@/components/dashboard/dashboard";
export default function ChatDetailPage() {
const params = useParams();
const chatId = params.id as string;
export const metadata: Metadata = {
title: "Chat Conversation",
description: "View and respond to customer messages",
};
export default function ChatDetailPage({ params }: { params: { id: string } }) {
return (
<Dashboard>
<ChatDetail chatId={chatId} />
<ChatDetail chatId={params.id} />
</Dashboard>
);
}

View File

@@ -10,23 +10,22 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { clientFetch } from "@/lib/client-utils";
import { getCookie } from "@/lib/client-utils";
import axios from "axios";
import { useRouter } from "next/navigation";
interface OrderProduct {
productId: string;
quantity: number;
pricePerUnit: number;
totalItemPrice: number;
}
interface Order {
_id: string;
orderId: number;
status: string;
totalPrice: number;
orderDate: string;
products: OrderProduct[];
products: Array<{
productId: string;
quantity: number;
pricePerUnit: number;
totalItemPrice: number;
}>;
}
interface BuyerOrderInfoProps {
@@ -34,26 +33,17 @@ interface BuyerOrderInfoProps {
chatId: string;
}
/**
* Component that displays order information for a buyer in a chat
* Shows a tooltip with recent orders and allows navigation to order details
*/
export default function BuyerOrderInfo({ buyerId, chatId }: BuyerOrderInfoProps) {
const router = useRouter();
// State
const [loading, setLoading] = useState(false);
const [orders, setOrders] = useState<Order[]>([]);
const [hasOrders, setHasOrders] = useState<boolean | null>(null);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
// Refs to prevent unnecessary re-renders and API calls
const lastFetchedRef = useRef<number>(0);
const isFetchingRef = useRef<boolean>(false);
const tooltipDelayRef = useRef<NodeJS.Timeout | null>(null);
/**
* Fetch buyer orders from the API
*/
// Fetch data without unnecessary dependencies to reduce render cycles
const fetchBuyerOrders = useCallback(async (force = false) => {
// Prevent multiple simultaneous fetches
if (isFetchingRef.current) return;
@@ -72,12 +62,27 @@ export default function BuyerOrderInfo({ buyerId, chatId }: BuyerOrderInfoProps)
setLoading(true);
try {
// Use clientFetch to handle auth and API routing automatically
const response = await clientFetch(`/chats/${chatId}/orders?limit=10`);
const authToken = getCookie("Authorization");
if (response && response.orders) {
setOrders(response.orders);
setHasOrders(response.orders.length > 0);
if (!authToken) {
isFetchingRef.current = false;
setLoading(false);
return;
}
const authAxios = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: {
Authorization: `Bearer ${authToken}`
}
});
// Use the new endpoint that works with sub-users
const response = await authAxios.get(`/chats/${chatId}/orders?limit=10`); // Limit to fewer orders for faster response
if (response.data && response.data.orders) {
setOrders(response.data.orders);
setHasOrders(response.data.orders.length > 0);
} else {
setHasOrders(false);
}
@@ -90,44 +95,48 @@ export default function BuyerOrderInfo({ buyerId, chatId }: BuyerOrderInfoProps)
setLoading(false);
isFetchingRef.current = false;
}
}, [chatId, orders.length, hasOrders]);
}, [chatId]); // Minimize dependencies even further
// Fetch orders when component mounts
// Start fetching immediately when component mounts
useEffect(() => {
if (chatId) {
// Immediately attempt to fetch in the background
fetchBuyerOrders();
}
return () => {
// Clean up any pending timeouts
if (tooltipDelayRef.current) {
clearTimeout(tooltipDelayRef.current);
}
};
}, [chatId, fetchBuyerOrders]);
/**
* Navigate to order details page
*/
const handleViewOrder = (orderId: string) => {
router.push(`/dashboard/orders/${orderId}`);
};
/**
* Handle mouse enter on the button to start loading data
*/
// Handle hover with immediate tooltip opening
const handleButtonMouseEnter = () => {
// Start fetching data, but don't wait for it to complete
if (!isFetchingRef.current) {
queueMicrotask(() => fetchBuyerOrders());
queueMicrotask(() => {
fetchBuyerOrders();
});
}
};
/**
* Handle tooltip state change, load data if opening
*/
// Handle tooltip state change
const handleTooltipOpenChange = (open: boolean) => {
setIsTooltipOpen(open);
if (open && !isFetchingRef.current) {
queueMicrotask(() => fetchBuyerOrders());
queueMicrotask(() => {
fetchBuyerOrders();
});
}
};
/**
* Format price as currency
*/
// Format the price as currency
const formatPrice = (price: number) => {
return `£${price.toFixed(2)}`;
};
@@ -137,26 +146,13 @@ export default function BuyerOrderInfo({ buyerId, chatId }: BuyerOrderInfoProps)
return null;
}
// Calculate total products across all orders
// Precompute product count for button display (only if we have orders)
const productCount = orders.length > 0
? orders.reduce((total, order) => {
return total + order.products.reduce((sum, product) => sum + product.quantity, 0);
}, 0)
: 0;
/**
* Get badge variant based on order status
*/
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "paid": return "paid";
case "unpaid": return "unpaid";
case "shipped": return "shipped";
case "completed": return "completed";
default: return "secondary";
}
};
return (
<TooltipProvider>
<Tooltip onOpenChange={handleTooltipOpenChange}>
@@ -215,10 +211,13 @@ export default function BuyerOrderInfo({ buyerId, chatId }: BuyerOrderInfoProps)
<Package className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Order #{order.orderId}</span>
</div>
<Badge
variant={getStatusBadgeVariant(order.status)}
className="text-[10px] h-5 px-1.5"
>
<Badge variant={
order.status === "paid" ? "paid" :
order.status === "unpaid" ? "unpaid" :
order.status === "shipped" ? "shipped" :
order.status === "completed" ? "completed" :
"secondary"
} className="text-[10px] h-5 px-1.5">
{order.status.toUpperCase()}
</Badge>
</div>

View File

@@ -8,8 +8,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, Send, RefreshCw, Search, User } from "lucide-react";
import axios from "axios";
import { toast } from "sonner";
import { clientFetch } from "@/lib/client-utils";
import { getCookie } from "@/lib/client-utils";
import debounce from "lodash/debounce";
interface User {
@@ -17,63 +18,60 @@ interface User {
telegramUsername: string | null;
}
interface Store {
_id: string;
name: string;
}
export default function NewChatForm() {
const router = useRouter();
const searchParams = useSearchParams();
// State management
const [buyerId, setBuyerId] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<User[]>([]);
const [searching, setSearching] = useState(false);
const [open, setOpen] = useState(false);
const [initialMessage, setInitialMessage] = useState("");
const [vendorStores, setVendorStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(false);
const [loadingUser, setLoadingUser] = useState(false);
const [vendorStores, setVendorStores] = useState<{ _id: string, name: string }[]>([]);
const [selectedStore, setSelectedStore] = useState<string>("");
const [selectedUser, setSelectedUser] = useState<User | null>(null);
// Loading states
const [searching, setSearching] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingUser, setLoadingUser] = useState(false);
const [loadingStores, setLoadingStores] = useState(false);
// Create an axios instance with auth
const getAuthAxios = () => {
const authToken = getCookie("Authorization");
if (!authToken) return null;
// UI state
const [open, setOpen] = useState(false);
return axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: { Authorization: `Bearer ${authToken}` }
});
};
// Parse URL parameters for buyerId and fetch user details if present
useEffect(() => {
const buyerIdParam = searchParams.get('buyerId');
if (buyerIdParam) {
setBuyerId(buyerIdParam);
// We'll fetch user details after stores are loaded
}
}, [searchParams]);
// Fetch vendor stores on component mount
useEffect(() => {
fetchVendorStores();
}, []);
// Fetch user information if buyer ID changes
useEffect(() => {
if (buyerId && vendorStores.length > 0) {
fetchUserById(buyerId);
}
}, [buyerId, vendorStores]);
// Fetch user information by ID
const fetchUserById = async (userId: string) => {
if (!userId || !vendorStores[0]?._id) return;
const authAxios = getAuthAxios();
if (!authAxios) {
toast.error("You need to be logged in");
router.push("/auth/login");
return;
}
setLoadingUser(true);
try {
const userData = await clientFetch(`/chats/user/${userId}`);
if (userData) {
setSelectedUser(userData);
setSearchQuery(userData.telegramUsername || `User ${userId}`);
const response = await authAxios.get(`/chats/user/${userId}`);
if (response.data) {
setSelectedUser(response.data);
setSearchQuery(response.data.telegramUsername || `User ${userId}`);
} else {
// Just leave the buyerId as is without username display
}
} catch (error) {
console.error("Error fetching user:", error);
@@ -83,47 +81,17 @@ export default function NewChatForm() {
}
};
// Fetch vendor stores
const fetchVendorStores = async () => {
setLoadingStores(true);
try {
// Get stores
const stores = await clientFetch('/storefront');
// Handle both array and single object responses
if (Array.isArray(stores)) {
setVendorStores(stores);
if (stores.length > 0) {
setSelectedStore(stores[0]._id);
}
} else if (stores && typeof stores === 'object' && stores._id) {
const singleStore = [stores];
setVendorStores(singleStore);
setSelectedStore(stores._id);
}
} catch (error) {
console.error("Error fetching stores:", error);
toast.error("Failed to load your stores");
// Redirect if there's a login issue
if (error instanceof Error && error.message.includes('logged in')) {
router.push("/auth/login");
}
} finally {
setLoadingStores(false);
}
};
// Debounced search function
const searchUsers = debounce(async (query: string) => {
if (!query.trim() || !selectedStore) return;
if (!query.trim() || !vendorStores[0]?._id) return;
const authAxios = getAuthAxios();
if (!authAxios) return;
setSearching(true);
try {
const results = await clientFetch(
`/chats/search/users?query=${encodeURIComponent(query)}&storeId=${selectedStore}`
);
setSearchResults(results);
setSearching(true);
const response = await authAxios.get(`/chats/search/users?query=${encodeURIComponent(query)}&storeId=${vendorStores[0]._id}`);
setSearchResults(response.data);
} catch (error) {
console.error("Error searching users:", error);
toast.error("Failed to search users");
@@ -147,40 +115,113 @@ export default function NewChatForm() {
setOpen(false);
};
// Navigation handlers
// Fetch vendor stores
useEffect(() => {
const fetchVendorStores = async () => {
const authAxios = getAuthAxios();
if (!authAxios) {
toast.error("You must be logged in to start a chat");
router.push("/auth/login");
return;
}
try {
// Get vendor profile first
const vendorResponse = await authAxios.get('/auth/me');
// Extract vendor ID properly
const vendorId = vendorResponse.data.vendor?._id;
if (!vendorId) {
console.error("Vendor ID not found in profile response:", vendorResponse.data);
toast.error("Could not retrieve vendor information");
return;
}
// Fetch store
const storeResponse = await authAxios.get(`/storefront`);
// Handle both array and single object responses
if (Array.isArray(storeResponse.data)) {
setVendorStores(storeResponse.data);
if (storeResponse.data.length > 0) {
setSelectedStore(storeResponse.data[0]._id);
}
} else if (storeResponse.data && typeof storeResponse.data === 'object' && storeResponse.data._id) {
const singleStore = [storeResponse.data];
setVendorStores(singleStore);
setSelectedStore(storeResponse.data._id);
} else {
console.error("Expected store data but received:", storeResponse.data);
setVendorStores([]);
toast.error("Failed to load store data in expected format");
}
// Now that we have the store, fetch user details if buyerId was set
const buyerIdParam = searchParams.get('buyerId');
if (buyerIdParam) {
fetchUserById(buyerIdParam);
}
} catch (error) {
console.error("Error fetching store:", error);
toast.error("Failed to load store");
setVendorStores([]);
}
};
fetchVendorStores();
}, []);
const handleBackClick = () => {
router.push("/dashboard/chats");
};
// Start new chat
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!buyerId || !initialMessage.trim() || !selectedStore) {
toast.error("Please fill in all required fields");
if (!buyerId) {
toast.error("Please select a customer");
return;
}
if (vendorStores.length === 0) {
toast.error("No store available. Please create a store first.");
return;
}
const storeId = vendorStores[0]._id;
setLoading(true);
try {
const response = await clientFetch('/chats', {
method: 'POST',
body: JSON.stringify({
buyerId,
storeId: selectedStore,
initialMessage: initialMessage.trim()
}),
headers: {
'Content-Type': 'application/json'
const authAxios = getAuthAxios();
if (!authAxios) {
toast.error("You need to be logged in");
router.push("/auth/login");
return;
}
const response = await authAxios.post("/chats/create", {
buyerId,
storeId: storeId,
initialMessage: initialMessage.trim() || undefined
});
// Navigate to the new chat
toast.success("Chat created successfully");
router.push(`/dashboard/chats/${response._id}`);
} catch (error) {
if (response.data.chatId) {
toast.success("Chat created successfully!");
router.push(`/dashboard/chats/${response.data.chatId}`);
} else if (response.data.error === "Chat already exists") {
toast.info("Chat already exists, redirecting...");
router.push(`/dashboard/chats/${response.data.chatId}`);
}
} catch (error: any) {
console.error("Error creating chat:", error);
toast.error("Failed to create chat. Please try again.");
if (error.response?.status === 409) {
toast.info("Chat already exists, redirecting...");
router.push(`/dashboard/chats/${error.response.data.chatId}`);
} else {
toast.error("Failed to create chat");
}
} finally {
setLoading(false);
}

View File

@@ -90,6 +90,9 @@ export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps)
try {
setIsSending(true);
const API_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_URL) throw new Error("API URL not configured");
// Get auth token from cookie
const authToken = document.cookie
.split("; ")

View File

@@ -51,7 +51,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
// If productData.image is a *URL* (string), show it as a default preview
useEffect(() => {
if (productData.image && typeof productData.image === "string" && productData._id) {
setImagePreview(`/api/products/${productData._id}/image`);
setImagePreview(`${process.env.NEXT_PUBLIC_API_URL}/products/${productData._id}/image`);
} else if (productData.image && typeof productData.image === "string") {
// Image exists but no ID, this is probably a new product
setImagePreview(null);

View File

@@ -1,87 +1,22 @@
/**
* Business motivation quotes for the dashboard
* Collection of quotes from successful entrepreneurs and business leaders
*/
// Random quotes for loading/dashboard screens
export interface Quote {
text: string;
author: string;
}
export const businessQuotes: Quote[] = [
// Steve Jobs quotes
{ text: "Your work is going to fill a large part of your life, and the only way to be truly satisfied is to do what you believe is great work.", author: "Steve Jobs" },
{ text: "Innovation distinguishes between a leader and a follower.", author: "Steve Jobs" },
{ text: "If you really look closely, most overnight successes took a long time.", author: "Steve Jobs" },
// Entrepreneurs and CEOs
{ text: "Your most unhappy customers are your greatest source of learning.", author: "Bill Gates" },
{ text: "The way to get started is to quit talking and begin doing.", author: "Walt Disney" },
{ text: "Opportunities don't happen. You create them.", author: "Chris Grosser" },
{ text: "The best way to predict the future is to create it.", author: "Peter Drucker" },
{ text: "If you are not willing to risk the usual, you will have to settle for the ordinary.", author: "Jim Rohn" },
{ text: "Chase the vision, not the money; the money will end up following you.", author: "Tony Hsieh" },
{ text: "It's not about ideas. It's about making ideas happen.", author: "Scott Belsky" },
{ text: "If you do build a great experience, customers tell each other about that. Word of mouth is very powerful.", author: "Jeff Bezos" },
// Persistence and growth
{ text: "The secret of getting ahead is getting started.", author: "Mark Twain" },
{ text: "Success is not final; failure is not fatal: It is the courage to continue that counts.", author: "Winston Churchill" },
{ text: "Don't watch the clock; do what it does. Keep going.", author: "Sam Levenson" },
{ text: "The future belongs to those who believe in the beauty of their dreams.", author: "Eleanor Roosevelt" },
{ text: "If you can't fly, then run. If you can't run, then walk. If you can't walk, then crawl. But whatever you do, you have to keep moving forward.", author: "Martin Luther King Jr." },
// Risk and innovation
{ text: "The biggest risk is not taking any risk. In a world that's changing quickly, the only strategy that is guaranteed to fail is not taking risks.", author: "Mark Zuckerberg" },
{ text: "I have not failed. I've just found 10,000 ways that won't work.", author: "Thomas Edison" },
{ text: "What would you do if you weren't afraid?", author: "Sheryl Sandberg" },
{ text: "When everything seems to be going against you, remember that the airplane takes off against the wind, not with it.", author: "Henry Ford" },
{ text: "If you're not embarrassed by the first version of your product, you've launched too late.", author: "Reid Hoffman" },
// Quality and execution
{ text: "The only place where success comes before work is in the dictionary.", author: "Vidal Sassoon" },
{ text: "Make every detail perfect and limit the number of details to perfect.", author: "Jack Dorsey" },
{ text: "There's no shortage of remarkable ideas, what's missing is the will to execute them.", author: "Seth Godin" },
{ text: "Always deliver more than expected.", author: "Larry Page" },
{ text: "Your reputation is more important than your paycheck, and your integrity is worth more than your career.", author: "Ryan Freitas" },
// Teamwork and determination
{ text: "No matter how brilliant your mind or strategy, if you're playing a solo game, you'll always lose out to a team.", author: "Reid Hoffman" },
{ text: "If you want to achieve greatness stop asking for permission.", author: "Anonymous" },
{ text: "Things work out best for those who make the best of how things work out.", author: "John Wooden" },
{ text: "The most valuable businesses of coming decades will be built by entrepreneurs who seek to empower people rather than try to make them obsolete.", author: "Peter Thiel" },
const quotes = [
"Checking inventory...",
"Analyzing market trends...",
"Connecting to secure channels...",
"Loading vendor dashboard...",
"Processing request...",
"Preparing your dashboard...",
"Calculating revenue metrics...",
"Gathering order data...",
"Initializing secure connection...",
"Syncing with database...",
"Loading vendor interface..."
];
/**
* Returns a random business quote from the collection
*/
export function getRandomQuote(): Quote {
const randomIndex = Math.floor(Math.random() * businessQuotes.length);
return businessQuotes[randomIndex];
export function getRandomQuote(): string {
const randomIndex = Math.floor(Math.random() * quotes.length);
return quotes[randomIndex];
}
/**
* Returns a random quote by a specific author if available,
* otherwise returns a random quote from any author
*/
export function getRandomQuoteByAuthor(author: string): Quote {
const authorQuotes = businessQuotes.filter(quote =>
quote.author.toLowerCase() === author.toLowerCase()
);
if (authorQuotes.length === 0) {
return getRandomQuote();
}
const randomIndex = Math.floor(Math.random() * authorQuotes.length);
return authorQuotes[randomIndex];
}
/**
* Returns quotes filtered by a theme or keyword in the text
*/
export function getQuotesByTheme(keyword: string): Quote[] {
return businessQuotes.filter(quote =>
quote.text.toLowerCase().includes(keyword.toLowerCase())
);
}
export default quotes;

View File

@@ -1,76 +0,0 @@
/**
* API utilities for consistent request handling
*/
/**
* Normalizes a URL to ensure it passes through the Next.js API proxy
* This ensures all client-side requests go through the Next.js rewrites.
*
* @param url The endpoint URL to normalize
* @returns A normalized URL with proper /api prefix
*/
export function normalizeApiUrl(url: string): string {
if (url.startsWith('/api/')) {
return url; // Already has /api/ prefix
} else {
// Add /api prefix, handling any leading slashes
const cleanUrl = url.startsWith('/') ? url : `/${url}`;
return `/api${cleanUrl}`;
}
}
/**
* Constructs a server-side API URL for backend requests
* Used in Server Components and API routes to directly access the backend API
*
* @param endpoint The API endpoint path
* @returns A complete URL to the backend API
*/
export function getServerApiUrl(endpoint: string): string {
const apiUrl = process.env.SERVER_API_URL || 'https://internal-api.inboxi.ng/api';
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
return apiUrl.endsWith('/')
? `${apiUrl}${cleanEndpoint}`
: `${apiUrl}/${cleanEndpoint}`;
}
/**
* Get the authentication token from cookies or localStorage
*/
export function getAuthToken(): string | null {
if (typeof document === 'undefined') return null; // Guard for SSR
return document.cookie
.split('; ')
.find(row => row.startsWith('Authorization='))
?.split('=')[1] || localStorage.getItem('Authorization');
}
/**
* Check if the user is logged in
*/
export function isAuthenticated(): boolean {
return !!getAuthToken();
}
/**
* Creates standard API request headers with authentication
*
* @param token Optional auth token (fetched automatically if not provided)
* @param customHeaders Additional headers to include
* @returns Headers object ready for fetch requests
*/
export function createApiHeaders(token?: string | null, customHeaders: Record<string, string> = {}): Headers {
const headers = new Headers({
'Content-Type': 'application/json',
...customHeaders
});
const authToken = token || getAuthToken();
if (authToken) {
headers.set('Authorization', `Bearer ${authToken}`);
}
return headers;
}

View File

@@ -1,7 +1,6 @@
'use client';
import { toast } from "@/components/ui/use-toast";
import { normalizeApiUrl, getAuthToken, createApiHeaders } from "./api-utils";
type FetchMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
@@ -12,26 +11,61 @@ interface FetchOptions {
headers?: HeadersInit;
}
/**
* Client-side fetch utility that ensures all requests go through the Next.js API proxy
*/
// Helper function to get auth token from cookies
function getAuthToken(): string | null {
if (typeof document === 'undefined') return null; // Guard for SSR
return document.cookie
.split('; ')
.find(row => row.startsWith('Authorization='))
?.split('=')[1] || null;
}
export async function fetchClient<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T> {
const { method = 'GET', body, headers = {}, ...rest } = options;
// Normalize the endpoint to ensure it starts with /api/
const url = normalizeApiUrl(endpoint);
// Get the base API URL from environment or fallback
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// Get auth token and prepare headers
const requestHeaders = createApiHeaders(null, headers as Record<string, string>);
// Ensure the endpoint starts with a slash
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
// For the specific case of internal-api.inboxi.ng - remove duplicate /api
let url;
if (apiUrl.includes('internal-api.inboxi.ng')) {
// Special case for internal-api.inboxi.ng
if (normalizedEndpoint.startsWith('/api/')) {
url = `${apiUrl}${normalizedEndpoint.substring(4)}`; // Remove the /api part
} else {
url = `${apiUrl}${normalizedEndpoint}`;
}
} else {
// Normal case for other environments
url = `${apiUrl}${normalizedEndpoint}`;
}
// Get auth token from cookies
const authToken = getAuthToken();
// Prepare headers with authentication if token exists
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(headers as Record<string, string>),
};
if (authToken) {
// Backend expects "Bearer TOKEN" format
requestHeaders['Authorization'] = `Bearer ${authToken}`;
console.log('Authorization header set to:', `Bearer ${authToken.substring(0, 10)}...`);
}
// Log request details (useful for debugging)
console.log('API Request:', {
url,
method,
hasAuthToken: requestHeaders.has('Authorization')
hasAuthToken: !!authToken
});
const fetchOptions: RequestInit = {
@@ -50,22 +84,24 @@ export async function fetchClient<T>(
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.message || errorData.error || `Request failed with status ${response.status}`;
const errorMessage = errorData.message || errorData.error || 'An error occurred';
throw new Error(errorMessage);
}
// Handle 204 No Content responses
if (response.status === 204) {
return {} as T;
}
return await response.json();
const data = await response.json();
return data;
} catch (error) {
console.error('API request failed:', error);
// Only show toast in browser environment
// Only show toast if this is a client-side error (not during SSR)
if (typeof window !== 'undefined') {
const message = error instanceof Error ? error.message : 'Failed to connect to server';
toast({
title: 'Error',
description: message,

View File

@@ -1,4 +1,47 @@
import { normalizeApiUrl, getAuthToken, createApiHeaders } from './api-utils';
/**
* Normalizes a URL to ensure it has the correct /api prefix
* This prevents double prefixing which causes API errors
*/
function normalizeApiUrl(url: string): string {
// Remove any existing /api or api prefix
const cleanPath = url.replace(/^\/?(api\/)+/, '');
// Add a single /api prefix
return `/api/${cleanPath.replace(/^\//, '')}`;
}
/**
* Get the authentication token from cookies or localStorage
*/
function getAuthToken(): string | null {
if (typeof document === 'undefined') return null; // Guard for SSR
return document.cookie
.split('; ')
.find(row => row.startsWith('Authorization='))
?.split('=')[1] || localStorage.getItem('Authorization');
}
/**
* Creates standard API request headers with authentication
*
* @param token Optional auth token (fetched automatically if not provided)
* @param customHeaders Additional headers to include
* @returns Headers object ready for fetch requests
*/
function createApiHeaders(token?: string | null, customHeaders: Record<string, string> = {}): Headers {
const headers = new Headers({
'Content-Type': 'application/json',
...customHeaders
});
const authToken = token || getAuthToken();
if (authToken) {
headers.set('Authorization', `Bearer ${authToken}`);
}
return headers;
}
/**
* Simple client-side fetch function for making API calls with Authorization header.
@@ -12,6 +55,8 @@ export async function clientFetch<T = any>(url: string, options: RequestInit = {
// Normalize URL to ensure it uses the Next.js API proxy
const fullUrl = normalizeApiUrl(url);
console.log(`Fetching URL: ${fullUrl}`);
const res = await fetch(fullUrl, {
...options,
headers,

View File

@@ -1,25 +1,11 @@
/**
* Client-side fetch function for API requests.
* A simple wrapper over fetch with improved error handling.
*/
export async function fetchData<T = any>(url: string, options: RequestInit = {}): Promise<T> {
export async function fetchData(url: string, options: RequestInit = {}): Promise<any> {
try {
const res = await fetch(url, options);
// Check for no content response
if (res.status === 204) {
return {} as T;
}
// Handle errors
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
const errorMessage = errorData.message || errorData.error || `Request failed: ${res.status} ${res.statusText}`;
throw new Error(errorMessage);
}
// Parse normal response
return await res.json();
if (!res.ok) throw new Error(`Request failed: ${res.statusText}`);
return res.json();
} catch (error) {
console.error(`Fetch error at ${url}:`, error);
throw error;

View File

@@ -1,12 +1,8 @@
import { fetchData } from '@/lib/data-service';
import { normalizeApiUrl } from './api-utils';
/**
* Fetches product data from the API
*/
export const fetchProductData = async (url: string, authToken: string) => {
try {
return await fetchData(normalizeApiUrl(url), {
return await fetchData(url, {
headers: { Authorization: `Bearer ${authToken}` },
credentials: "include",
});
@@ -16,9 +12,6 @@ export const fetchProductData = async (url: string, authToken: string) => {
}
};
/**
* Saves product data to the API
*/
export const saveProductData = async (
url: string,
data: any,
@@ -26,7 +19,7 @@ export const saveProductData = async (
method: "POST" | "PUT" = "POST"
) => {
try {
return await fetchData(normalizeApiUrl(url), {
return await fetchData(url, {
method,
headers: {
Authorization: `Bearer ${authToken}`,
@@ -41,15 +34,12 @@ export const saveProductData = async (
}
};
/**
* Uploads a product image
*/
export const saveProductImage = async(url: string, file:File, authToken: string) => {
try{
const formData = new FormData();
formData.append("file", file);
return await fetchData(normalizeApiUrl(url), {
return await fetchData(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${authToken}`,
@@ -62,12 +52,9 @@ export const saveProductImage = async(url: string, file: File, authToken: string
}
}
/**
* Deletes a product
*/
export const deleteProductData = async (url: string, authToken: string) => {
try {
return await fetchData(normalizeApiUrl(url), {
return await fetchData(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${authToken}`,
@@ -81,12 +68,10 @@ export const deleteProductData = async (url: string, authToken: string) => {
}
};
/**
* Fetches product stock information
*/
// Stock management functions
export const fetchStockData = async (url: string, authToken: string) => {
try {
return await fetchData(normalizeApiUrl(url), {
return await fetchData(url, {
headers: { Authorization: `Bearer ${authToken}` },
credentials: "include",
});
@@ -96,9 +81,6 @@ export const fetchStockData = async (url: string, authToken: string) => {
}
};
/**
* Updates product stock information
*/
export const updateProductStock = async (
productId: string,
stockData: {
@@ -109,7 +91,7 @@ export const updateProductStock = async (
authToken: string
) => {
try {
const url = `/api/stock/${productId}`;
const url = `${process.env.NEXT_PUBLIC_API_URL}/stock/${productId}`;
return await fetchData(url, {
method: "PUT",
headers: {

View File

@@ -1,6 +1,21 @@
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getServerApiUrl } from './api-utils';
/**
* Constructs a server-side API URL for backend requests
* Used in Server Components and API routes to directly access the backend API
*
* @param endpoint The API endpoint path
* @returns A complete URL to the backend API
*/
function getServerApiUrl(endpoint: string): string {
const apiUrl = process.env.SERVER_API_URL || 'https://internal-api.inboxi.ng/api';
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
return apiUrl.endsWith('/')
? `${apiUrl}${cleanEndpoint}`
: `${apiUrl}/${cleanEndpoint}`;
}
/**
* Server-side fetch wrapper with authentication.

View File

@@ -1,22 +1,9 @@
import { fetchData } from '@/lib/data-service';
import { normalizeApiUrl } from './api-utils';
/**
* Interface for shipping method data
*/
interface ShippingMethod {
name: string;
price: number;
_id?: string;
}
/**
* Fetches all shipping methods for the current store
*/
export const fetchShippingMethods = async (authToken: string) => {
try {
const res = await fetchData(
`/api/shipping-options`,
`${process.env.NEXT_PUBLIC_API_URL}/shipping-options`,
{
headers: {
Authorization: `Bearer ${authToken}`,
@@ -36,16 +23,19 @@ export const fetchShippingMethods = async (authToken: string) => {
}
};
/**
* Adds a new shipping method
*/
interface ShippingMethod {
name: string;
price: number;
_id?: string;
}
export const addShippingMethod = async (
authToken: string,
newShipping: Omit<ShippingMethod, "_id">
): Promise<ShippingMethod[]> => {
try {
const res = await fetchData(
`/api/shipping-options`,
`${process.env.NEXT_PUBLIC_API_URL}/shipping-options`,
{
method: "POST",
headers: {
@@ -79,13 +69,10 @@ export const addShippingMethod = async (
}
};
/**
* Deletes a shipping method by ID
*/
export const deleteShippingMethod = async (authToken: string, id: string) => {
try {
const res = await fetchData(
`/api/shipping-options/${id}`,
`${process.env.NEXT_PUBLIC_API_URL}/shipping-options/${id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${authToken}` },
@@ -101,17 +88,14 @@ export const deleteShippingMethod = async (authToken: string, id: string) => {
}
};
/**
* Updates an existing shipping method
*/
export const updateShippingMethod = async (
authToken: string,
id: string,
updatedShipping: Partial<ShippingMethod>
updatedShipping: any
) => {
try {
const res = await fetchData(
`/api/shipping-options/${id}`,
`${process.env.NEXT_PUBLIC_API_URL}/shipping-options/${id}`,
{
method: "PUT",
headers: {

View File

@@ -1,17 +1,11 @@
import { fetchData } from '@/lib/data-service';
import { normalizeApiUrl } from './api-utils';
/**
* Sends authenticated API requests, ensuring they go through the Next.js API proxy
*/
export const apiRequest = async <T = any>(endpoint: string, method: string = "GET", body?: T | null) => {
try {
// Enforce client-side execution
if (typeof document === "undefined") {
throw new Error("API requests must be made from the client side.");
}
// Get authentication token
const authToken = document.cookie
.split("; ")
.find((row) => row.startsWith("Authorization="))
@@ -22,7 +16,6 @@ export const apiRequest = async <T = any>(endpoint: string, method: string = "GE
throw new Error("No authentication token found");
}
// Prepare request options
const options: RequestInit = {
method,
headers: {
@@ -36,10 +29,10 @@ export const apiRequest = async <T = any>(endpoint: string, method: string = "GE
options.body = JSON.stringify(body);
}
// Normalize URL to ensure it uses the Next.js API proxy
const url = normalizeApiUrl(endpoint);
const API_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_URL) throw new Error("NEXT_PUBLIC_API_URL is not set in environment variables");
const res = await fetchData(url, options);
const res = await fetchData(`${API_URL}${endpoint}`, options);
if (!res) {
const errorResponse = await res.json().catch(() => null);
@@ -50,11 +43,11 @@ export const apiRequest = async <T = any>(endpoint: string, method: string = "GE
return res;
} catch (error: unknown) {
if (error instanceof Error) {
console.error(`API Request Error: ${error.message}`);
console.error(`🚨 API Request Error: ${error.message}`);
throw new Error(error.message);
}
console.error("An unknown error occurred", error);
console.error("An unknown error occurred", error);
throw new Error("An unknown error occurred");
}
};

16
package-lock.json generated
View File

@@ -49,6 +49,7 @@
"lucide-react": "^0.454.0",
"next": "^15.2.3",
"next-themes": "latest",
"pusher-js": "^8.4.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
@@ -5763,6 +5764,15 @@
"node": ">=6"
}
},
"node_modules/pusher-js": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6623,6 +6633,12 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -53,6 +53,7 @@
"lucide-react": "^0.454.0",
"next": "^15.2.3",
"next-themes": "latest",
"pusher-js": "^8.4.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",