backsplash-app
Version:
An AI powered wallpaper app.
785 lines (686 loc) • 30.8 kB
text/typescript
import axios, { AxiosError } from "axios";
import {
WallpaperGenerationResponse,
WallpaperUpscaleResponse,
WallpapersResponse,
WallpaperGenerationRawResponse,
WallpaperLocationData,
} from "@/types/wallpaper";
import { StoreService } from "@/ipc/services/storeService";
// Use the variable name that is defined for the main process environment by Vite
const API_BASE_URL = process.env.SERVER_BASE_URL || "";
console.log(`[ApiService] Effective API_BASE_URL: "${API_BASE_URL}"`);
const R2_PUBLIC_WALLPAPER_URL = process.env.R2_PUBLIC_WALLPAPER_URL || "https://storage.backsplashai.com"; // Default added for clarity, ensure env var is set
// Ensure we have a valid base URL for API calls
if (!API_BASE_URL) {
throw new Error("VITE_SERVER_BASE_URL is not set");
}
// Ensure we have a valid R2 public URL for direct image access
if (!R2_PUBLIC_WALLPAPER_URL) {
console.warn("R2_PUBLIC_WALLPAPER_URL is not set. Direct image URLs may not work.");
// Depending on strictness, you might want to throw an error here too
}
// Set axios defaults
axios.defaults.baseURL = API_BASE_URL;
axios.defaults.timeout = 60000; // 60 second timeout for all requests
// Cache TTL for categories (1 day in milliseconds)
const CATEGORIES_CACHE_TTL = 24 * 60 * 60 * 1000;
// Add these constants at the top
const CACHE_REVALIDATION_DEBOUNCE = 1000; // 1 second
const MAX_CACHE_RETRIES = 3;
const LICENSE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
const LICENSE_GRACE_PERIOD = 7 * 24 * 60 * 60 * 1000; // 7 days grace period for offline use
// Add this utility function
const debounce = (fn: (...args: any[]) => void, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};
// Track ongoing background operations to prevent duplicates
let isBackgroundRevalidationInProgress = false;
// Helper to validate ISO string format
const isValidISOString = (str: string): boolean => {
if (typeof str !== "string") return false;
try {
// Check if it matches ISO 8601 format
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(str);
} catch (e) {
return false;
}
};
// Helper function to get the URL for a wallpaper image via our server proxy
export const getWallpaperImageUrl = (objectKey: string): string => {
// Ensure we have a valid object key
if (!objectKey) {
console.warn("[ApiService] Invalid objectKey provided to getWallpaperImageUrl:", objectKey);
return "";
}
if (!R2_PUBLIC_WALLPAPER_URL) {
console.error("[ApiService] R2_PUBLIC_WALLPAPER_URL is not set. Cannot form wallpaper image URL.");
return "";
}
// Ensure base URL doesn't have trailing slash and objectKey doesn't have leading slash for clean join
const base = R2_PUBLIC_WALLPAPER_URL.endsWith("/") ? R2_PUBLIC_WALLPAPER_URL.slice(0, -1) : R2_PUBLIC_WALLPAPER_URL;
const key = objectKey.startsWith("/") ? objectKey.slice(1) : objectKey;
const directUrl = `${base}/${key}`;
console.log(`[ApiService] Creating direct wallpaper URL for objectKey: ${objectKey} → ${directUrl}`);
return directUrl;
};
// Helper function to get the URL for a category preview image
// REMOVED - backend now provides this directly in category.preview_url
// Helper function to get the URL for a style preview image
// REMOVED - backend now provides this directly in style.preview_url
// License service functions
const validateLicense = async (storeService: StoreService): Promise<boolean> => {
try {
const licenseKey = storeService.get<"license-key">("license-key");
if (!licenseKey) {
console.log("[LicenseService] No license key found");
return false;
}
// Get device ID to include with validation
const deviceIdData = storeService.get<"device-id">("device-id");
const deviceId = deviceIdData?.data || crypto.randomUUID();
// Get cached validation to check if we need to validate again
const cachedValidation = storeService.get<"license-validation">("license-validation");
const now = Date.now(); // Keep using numeric timestamps for license validation
// If we have a recent validation and it's active, use it
if (
cachedValidation &&
cachedValidation.last_checked &&
now - cachedValidation.last_checked < LICENSE_CHECK_INTERVAL &&
cachedValidation.valid &&
cachedValidation.subscription_active
) {
console.log("[LicenseService] Using cached license validation");
return true;
}
// Otherwise validate against the server
console.log(`[LicenseService] Validating license key with server for client: ${deviceId.substring(0, 8)}...`);
const response = await axios.post("/wallpapers/validate-license", { license_key: licenseKey, client_id: deviceId });
// Update the cache with the server response (map FastAPI response to our format)
const isPremium = response.data.plan === "premium";
storeService.set<"license-validation">("license-validation", {
valid: response.data.valid || false,
subscription_active: isPremium, // Map premium plan to subscription_active
plan: response.data.plan || "free",
expires_at: 0, // Our licenses don't expire
last_checked: now, // Keep using numeric timestamp for license check
});
return response.data.valid && isPremium;
} catch (error) {
console.error("[LicenseService] Error validating license:", error);
// Fall back to cached validation if offline
const cachedValidation = storeService.get<"license-validation">("license-validation");
// Allow grace period if we had a valid subscription before
if (cachedValidation && cachedValidation.last_checked) {
const gracePeriodValid = Date.now() - cachedValidation.last_checked < LICENSE_GRACE_PERIOD;
if (gracePeriodValid && cachedValidation.valid && cachedValidation.subscription_active) {
console.warn("[LicenseService] Using cached license during grace period due to validation error");
return true;
}
}
return false;
}
};
const setLicenseKey = async (storeService: StoreService, licenseKey: string): Promise<boolean> => {
try {
// Save the license key
storeService.set<"license-key">("license-key", licenseKey);
// Immediately validate it
return await validateLicense(storeService);
} catch (error) {
console.error("[LicenseService] Error setting license key:", error);
return false;
}
};
// Types
export interface Style {
label: string;
// image: string; // Old field
preview_url: string; // Direct URL from backend
tier?: "free" | "premium"; // Style tier information
}
export interface Category {
id: string;
title: string;
styles: Style[];
preview_url: string; // Direct URL from backend
}
// Custom error handler for axios requests
const handleAxiosError = (error: AxiosError, context: string) => {
// Log detailed error
console.error(`Error in ${context}:`, error);
// Format a more informative error message based on the type of error
let errorMessage: string;
if (error.code === "ECONNREFUSED") {
errorMessage = `Cannot connect to server.`;
} else if (error.code === "ETIMEDOUT") {
errorMessage = "Connection timed out. Server may be overloaded or unreachable.";
} else if (error.response) {
// Server responded with error status
const responseData = error.response.data as Record<string, unknown>;
errorMessage = `Server error: ${error.response.status} - ${responseData?.detail || "Unknown error"}`;
} else if (error.request) {
// Request was made but no response received
errorMessage = "No response from server. Please check your network connection.";
} else {
// Error in setting up the request
errorMessage = error.message || "Unknown error occurred";
}
// Create an error with the message that can be passed back to the renderer
const enhancedError = new Error(errorMessage);
enhancedError.stack = error.stack;
throw enhancedError;
};
// API service that handles interagration with the FastAPI backend
const createApiService = (storeService: StoreService) => ({
/**
* Check if the API server is healthy
*/
checkHealth: async (): Promise<{ isHealthy: boolean; message: string }> => {
try {
const response = await axios.get("/health", { timeout: 2000 });
return {
isHealthy: response.status === 200,
message: response.data?.status || "API is reachable",
};
} catch (error) {
console.error("Health check failed:", error);
return {
isHealthy: false,
message: "Cannot connect to the backend server",
};
}
},
/**
* License-related functionality
*/
license: {
/**
* Validate the current license key with the server
*/
validate: async (): Promise<boolean> => {
return validateLicense(storeService);
},
/**
* Set and validate a new license key
*/
setLicenseKey: async (licenseKey: string): Promise<boolean> => {
return setLicenseKey(storeService, licenseKey);
},
/**
* Get current license information
*/
getLicenseInfo: () => {
const licenseKey = storeService.get<"license-key">("license-key");
const validation = storeService.get<"license-validation">("license-validation");
return {
licenseKey,
isValid: validation?.valid || false,
isActive: validation?.subscription_active || false,
plan: validation?.plan || "free",
expiresAt: validation?.expires_at || 0,
lastChecked: validation?.last_checked || 0,
};
},
},
categories: {
/**
* Fetch all categories and their styles with stale-while-revalidate caching
*/
getMany: async (): Promise<Category[]> => {
try {
// Get license information to filter styles appropriately
const licenseValidation = storeService.get<"license-validation">("license-validation");
const licensePlan = licenseValidation?.plan || "free";
// Get cached data
const cached = storeService.get<"categories">("categories");
const now = Date.now();
// Check if cache is for the same license plan and contains tier information
const cachedForPlan = cached?.timestamp && cached?.licensePlan === licensePlan;
const hasTierInfo = cached?.data?.length > 0 && cached.data[0]?.styles?.[0]?.tier !== undefined;
// If we have valid cache for this plan with tier info that isn't stale, return data as is
if (
cached?.data?.length > 0 &&
cachedForPlan &&
hasTierInfo &&
now - cached.timestamp <= CATEGORIES_CACHE_TTL
) {
// console.log(`[ApiService] Returning categories from valid cache for ${licensePlan} plan`);
return cached.data;
}
// If we have stale data for this plan or missing tier info, use it but revalidate in the background
if (cached?.data?.length > 0 && cachedForPlan && hasTierInfo) {
// console.log(`[ApiService] Returning categories from stale cache for ${licensePlan} plan, revalidating in background`);
// Debounced revalidation to prevent multiple simultaneous requests
const debouncedRevalidate = debounce(async () => {
// Prevent multiple simultaneous background revalidations
if (isBackgroundRevalidationInProgress) {
console.warn("Background revalidation already in progress, skipping");
return;
}
try {
isBackgroundRevalidationInProgress = true;
// Quick health check before attempting revalidation
const apiService = createApiService(storeService);
const healthCheck = await apiService.checkHealth();
if (healthCheck.isHealthy) {
await apiService.categories.revalidateCache();
} else {
console.warn("Server unhealthy, skipping background categories revalidation");
}
} catch (error) {
// Only log background revalidation errors, don't throw them
console.warn("Background revalidation failed (this is non-critical):", error);
} finally {
isBackgroundRevalidationInProgress = false;
}
}, CACHE_REVALIDATION_DEBOUNCE);
debouncedRevalidate();
return cached.data;
}
// If we have no cache, cache for different plan, or missing tier info, fetch fresh data
console.log(`[ApiService] No valid cache for categories (${licensePlan} plan), fetching fresh data`);
const freshData = await createApiService(storeService).categories.fetchFresh();
return freshData; // Already has direct URLs and tier info
} catch (error) {
console.error("Error getting categories:", error);
// If we have any cached data for current plan with tier info, return it as fallback
const cached = storeService.get<"categories">("categories");
const licenseValidation = storeService.get<"license-validation">("license-validation");
const licensePlan = licenseValidation?.plan || "free";
const hasTierInfo = cached?.data?.length > 0 && cached.data[0]?.styles?.[0]?.tier !== undefined;
if (cached?.data?.length > 0 && cached?.licensePlan === licensePlan && hasTierInfo) {
// console.log(`[ApiService] Error fetching categories, returning from cache as fallback for ${licensePlan} plan`);
return cached.data; // Already has direct URLs and tier info
}
// Only throw error if we have no cache to fall back on
handleAxiosError(error as AxiosError, "getCategories");
}
},
/**
* Fetch fresh categories data and update cache with retry logic
*/
fetchFresh: async (retryCount = 0): Promise<Category[]> => {
try {
// Get license information to pass to API
const licenseValidation = storeService.get<"license-validation">("license-validation");
const licensePlan = licenseValidation?.plan || "free";
console.log(`[ApiService] Fetching fresh categories for ${licensePlan} plan...`);
const response = await axios.get<Category[]>("/categories", {
params: { license_plan: licensePlan },
});
console.log(
`[ApiService] Raw response from server for categories (${licensePlan} plan):`,
JSON.stringify(response.data, null, 2),
);
// Data from backend should now have direct preview_urls for categories and styles
const data = response.data;
// console.log("[ApiService] Processed categories with direct URLs:", JSON.stringify(data, null, 2));
// Update cache with license plan information
storeService.set<"categories">("categories", {
data,
timestamp: Date.now(),
licensePlan: licensePlan,
});
return data;
} catch (error) {
console.error("Error fetching fresh categories:", error);
// Implement retry logic
if (retryCount < MAX_CACHE_RETRIES) {
console.log(`Retrying fetch (attempt ${retryCount + 1}/${MAX_CACHE_RETRIES})...`);
await new Promise((resolve) => setTimeout(resolve, 1000 * (retryCount + 1))); // Exponential backoff
return createApiService(storeService).categories.fetchFresh(retryCount + 1);
}
throw error;
}
},
/**
* Revalidate cache in the background without blocking
*/
revalidateCache: async (): Promise<void> => {
try {
// Get license information to pass to API
const licenseValidation = storeService.get<"license-validation">("license-validation");
const licensePlan = licenseValidation?.plan || "free";
const response = await axios.get<Category[]>("/categories", {
params: { license_plan: licensePlan },
});
// Data from backend should now have direct preview_urls
const data = response.data;
// Update cache with license plan information
storeService.set<"categories">("categories", {
data,
timestamp: Date.now(),
licensePlan: licensePlan,
});
console.log(`Categories cache revalidated successfully for ${licensePlan} plan`);
} catch (error) {
// Background revalidation errors should be silent and non-blocking
console.warn("Error revalidating categories cache (background operation):", error);
// Just log the error but don't throw - this is a background operation
// and shouldn't affect the user experience
}
},
/**
* Fetch a specific category by ID
*/
get: async (categoryId: string): Promise<Category> => {
try {
// Get license information
const licenseValidation = storeService.get<"license-validation">("license-validation");
const licensePlan = licenseValidation?.plan || "free";
// Check if we have this category in the cache for the current plan
const cached = storeService.get<"categories">("categories");
if (cached?.data?.length > 0 && cached?.licensePlan === licensePlan) {
const cachedCategory = cached.data.find((cat: Category) => cat.id === categoryId);
if (cachedCategory) {
return cachedCategory;
}
}
// Otherwise fetch it from the API with license plan
const response = await axios.get<Category>(`/categories/${categoryId}`, {
params: { license_plan: licensePlan },
});
return response.data;
} catch (error) {
console.error(`Error fetching category ${categoryId}:`, error);
throw error;
}
},
},
wallpapers: {
/**
* Fetch wallpapers with optional pagination
*/
getMany: async (nextToken?: string) => {
try {
const params = nextToken
? { max_items: 30, page_size: 30, continuation_token: nextToken }
: { max_items: 30, page_size: 30 };
const response = await axios.get<WallpapersResponse>("/wallpapers", { params });
// Add imageUrl to each wallpaper
const wallpapersWithUrls = response.data.wallpapers.map((wallpaper) => ({
...wallpaper,
imageUrl: getWallpaperImageUrl(wallpaper.key),
}));
return {
...response.data,
wallpapers: wallpapersWithUrls,
};
} catch (error) {
console.error("Error fetching wallpapers:", error);
throw error;
}
},
/**
* Get metadata for a specific wallpaper
*/
getMetadata: async (objectKey: string) => {
try {
// Extract just the filename part if it includes a path
const parts = objectKey.split("/");
const filename = parts[parts.length - 1];
console.log(
`[ApiService] Fetching metadata for ${filename} from ${axios.defaults.baseURL}/wallpapers/metadata/${filename}`,
);
const response = await axios.get(`/wallpapers/metadata/${filename}`);
// Log the actual response data to see what we're getting
console.log(`[ApiService] Metadata response for ${filename}:`, JSON.stringify(response.data, null, 2));
if (response.data && !response.data.style) {
console.warn(`[ApiService] No style found in metadata response for ${filename}`);
}
// If response has a message about no metadata, return a better default
if (response.data && response.data.message === "No metadata found for this image") {
console.warn(`[ApiService] No metadata found for ${filename}, using default metadata`);
// Try to extract style from object key if possible
let inferredStyle = "Unknown";
// Check for dash-separated style (many generated images use {Style}-{type}-{uuid} format)
if (filename.includes("-")) {
const stylePart = filename.split("-")[0];
if (stylePart && stylePart.length > 0) {
inferredStyle = stylePart;
console.log(`[ApiService] Inferred style "${inferredStyle}" from filename using dash pattern`);
}
}
// Fallback to underscore pattern if no dash pattern match
else if (filename.includes("_")) {
inferredStyle = filename.split("_")[0];
console.log(`[ApiService] Inferred style "${inferredStyle}" from filename using underscore pattern`);
}
return {
style: inferredStyle,
created_at: new Date().toISOString(),
inferred: true,
};
}
return response.data;
} catch (error) {
console.error(`[ApiService] Error fetching metadata for ${objectKey}:`, error);
if (error.response) {
console.error(`[ApiService] Error status: ${error.response.status}, data:`, error.response.data);
}
// Try to extract style from object key as a fallback
const parts = objectKey.split("/");
const filename = parts[parts.length - 1];
let inferredStyle = "Unknown";
// Check for dash-separated style (many generated images use {Style}-{type}-{uuid} format)
if (filename.includes("-")) {
const stylePart = filename.split("-")[0];
if (stylePart && stylePart.length > 0) {
inferredStyle = stylePart;
console.log(
`[ApiService] Inferred style "${inferredStyle}" from filename using dash pattern (error fallback)`,
);
}
}
// Fallback to underscore pattern if no dash pattern match
else if (filename.includes("_")) {
inferredStyle = filename.split("_")[0];
console.log(
`[ApiService] Inferred style "${inferredStyle}" from filename using underscore pattern (error fallback)`,
);
}
return {
style: inferredStyle,
created_at: new Date().toISOString(),
inferred: true,
};
}
},
/**
* Generate a new wallpaper
*/
generate: async (requestData?: {
style?: string;
custom_style?: string;
location_data?: WallpaperLocationData;
mode?: string;
}): Promise<WallpaperGenerationResponse> => {
try {
console.log(`[ApiService] Sending generate wallpaper request`, requestData);
// Add a unique client ID to each request to track what images each client has seen
// Get a device ID from store or generate a new one if it doesn't exist
const deviceIdData = storeService.get<"device-id">("device-id");
const deviceId = deviceIdData?.data || crypto.randomUUID();
// Log detailed device ID information
if (deviceIdData?.data) {
console.log(`[ApiService] Using existing device ID: ${deviceId.substring(0, 8)}...`);
} else {
console.log(`[ApiService] Generated new device ID: ${deviceId.substring(0, 8)}...`);
}
// Store the device ID if it's new
if (!deviceIdData) {
storeService.set<"device-id">("device-id", {
data: deviceId,
timestamp: Date.now(), // Keep as numeric timestamp for compatibility
});
console.log(`[ApiService] Saved new device ID to electron-store`);
}
// Get license information to include with the request
const licenseKey = storeService.get<"license-key">("license-key");
const licenseValidation = storeService.get<"license-validation">("license-validation");
// Check if we need to refresh the license validation
if (
licenseKey &&
(!licenseValidation?.last_checked || Date.now() - licenseValidation.last_checked > LICENSE_CHECK_INTERVAL)
) {
// Don't await this - we'll validate asynchronously
console.log(`[ApiService] Starting background license validation`);
validateLicense(storeService).catch((err) => {
console.error("[ApiService] Background license validation failed:", err);
});
}
// Process location_data to ensure timestamps are properly formatted for API
let processedLocationData = requestData?.location_data;
if (processedLocationData && typeof processedLocationData === "object") {
// Ensure local_time_str is a valid ISO string
if (processedLocationData.local_time_str) {
// If it's not already a valid ISO string (might be a different string format),
// try to convert it to one
if (!isValidISOString(processedLocationData.local_time_str)) {
try {
// Try to parse and then format as ISO
processedLocationData = {
...processedLocationData,
local_time_str: new Date(processedLocationData.local_time_str).toISOString(),
};
console.log(
`[ApiService] Converted local_time_str to ISO format: ${processedLocationData.local_time_str}`,
);
} catch (err) {
console.error(
`[ApiService] Failed to convert local_time_str to ISO format: ${processedLocationData.local_time_str}`,
err,
);
// If conversion fails, provide a fallback
processedLocationData = {
...processedLocationData,
local_time_str: new Date().toISOString(),
};
}
}
} else {
// If no local_time_str is provided, add current time
processedLocationData = {
...processedLocationData,
local_time_str: new Date().toISOString(),
};
}
}
// Get custom API key and selected model from store
const customApiKey = storeService.get<"custom-replicate-api-key">("custom-replicate-api-key");
const selectedModel = storeService.get<"selected-image-model">("selected-image-model");
// Include the client_id, license info, custom API key, and model selection in the request
const requestWithClientData = {
...requestData,
location_data: processedLocationData,
client_id: deviceId,
license_key: licenseKey || "",
license_plan: licenseValidation?.plan || "free",
model_provider: selectedModel || "recraft-v3",
custom_replicate_api_key: customApiKey || "",
};
console.log(`[ApiService] Request will include client_id: ${deviceId.substring(0, 8)}...`);
if (processedLocationData && processedLocationData !== requestData?.location_data) {
console.log(`[ApiService] Processed location data: ${JSON.stringify(processedLocationData)}`);
}
console.log(
`[ApiService] Attempting POST to /wallpapers/generate with data:`,
JSON.stringify(requestWithClientData, null, 2),
);
const response = await axios.post<WallpaperGenerationRawResponse>(
"/wallpapers/generate",
requestWithClientData || null,
);
// Return the full response data, including filename and object_key
return {
...response.data,
objectKey: response.data.object_key,
};
} catch (error) {
return handleAxiosError(error, "generateWallpaper");
}
},
/**
* Upscale an existing wallpaper
*/
upscale: async (params: {
object_key: string;
model_provider: string;
license_key: string;
license_plan: string;
client_id: string;
}) => {
try {
// Get custom API key if available
const customApiKey = storeService.get<"custom-replicate-api-key">("custom-replicate-api-key");
console.log(`[ApiService] Sending upscale request for ${params.object_key}`);
console.log(`[ApiService] Upscale request includes client_id: ${params.client_id.substring(0, 8)}...`);
console.log(`[ApiService] Using upscale provider: ${params.model_provider}`);
const response = await axios.post<WallpaperUpscaleResponse>("/wallpapers/upscale", null, {
params: {
object_key: params.object_key,
license_key: params.license_key,
license_plan: params.license_plan,
client_id: params.client_id,
model_provider: params.model_provider,
custom_replicate_api_key: customApiKey || "",
},
});
const data = response.data;
const upscaledR2Key = data.object_key;
const imageUrl = getWallpaperImageUrl(upscaledR2Key);
// Ensure URL has a valid protocol
let validImageUrl = imageUrl;
if (imageUrl && !imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) {
console.warn(`[ApiService] Upscaled image URL (${imageUrl}) from getWallpaperImageUrl lacks protocol.`);
if (axios.defaults.baseURL) {
try {
const baseUrlObj = new URL(axios.defaults.baseURL);
validImageUrl = `${baseUrlObj.origin}${imageUrl.startsWith("/") ? imageUrl : `/${imageUrl}`}`;
console.log(`[ApiService] Fixed upscaled URL to: ${validImageUrl}`);
} catch (urlError) {
console.error(`[ApiService] Error parsing base URL for fallback: ${axios.defaults.baseURL}`, urlError);
validImageUrl = imageUrl;
}
} else {
validImageUrl = `https://${imageUrl}`;
console.warn(`[ApiService] Blindly prepended https to: ${validImageUrl}`);
}
}
console.log(`[ApiService] Created upscaled image URL: ${validImageUrl} for key: ${data.object_key}`);
return {
...data,
imageUrl: validImageUrl,
};
} catch (error) {
console.error("Error upscaling wallpaper:", error);
throw error;
}
},
},
/**
* Submit user feedback to the server
*/
feedback: {
submit: async (content: string, userId?: string, rating?: number, category?: string) => {
try {
const response = await axios.post("/feedback", {
user_id: userId,
content,
rating,
category,
});
return response.data;
} catch (error) {
console.error("Error submitting feedback:", error);
return handleAxiosError(error, "submitFeedback");
}
},
},
});
export default createApiService;