Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser, type AdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
export async function getCurrentAdminUser(): Promise<AdminUser | null> {
|
||||
return getAdminUser();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use server";
|
||||
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
|
||||
function getServiceClient() {
|
||||
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
|
||||
return createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
roleKey,
|
||||
);
|
||||
}
|
||||
|
||||
type AdminActionPayload = {
|
||||
action_type: "create" | "update" | "delete";
|
||||
admin_id?: string;
|
||||
admin_email?: string;
|
||||
affected_user_id?: string;
|
||||
brand_id?: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type UserActivityPayload = {
|
||||
user_id: string;
|
||||
activity_type: "login" | "logout" | "password_change" | "profile_update" | "email_change";
|
||||
details?: Record<string, unknown>;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
};
|
||||
|
||||
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
|
||||
try {
|
||||
const service = getServiceClient();
|
||||
await service.rpc("log_admin_action", {
|
||||
p_payload: {
|
||||
action_type: payload.action_type,
|
||||
admin_id: payload.admin_id ?? null,
|
||||
admin_email: payload.admin_email ?? null,
|
||||
affected_user_id: payload.affected_user_id ?? null,
|
||||
brand_id: payload.brand_id ?? null,
|
||||
details: payload.details ?? {},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// logging failed silently
|
||||
}
|
||||
}
|
||||
|
||||
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
|
||||
try {
|
||||
const service = getServiceClient();
|
||||
await service.rpc("log_user_activity", {
|
||||
p_payload: {
|
||||
user_id: payload.user_id,
|
||||
activity_type: payload.activity_type,
|
||||
details: payload.details ?? {},
|
||||
ip_address: payload.ip_address ?? null,
|
||||
user_agent: payload.user_agent ?? null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// logging failed silently
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use server";
|
||||
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
|
||||
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
|
||||
const cookieStore = await cookies();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = NextResponse.next();
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll(); },
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert dev platform_admin record
|
||||
const { data: existing } = await supabase
|
||||
.from("admin_users")
|
||||
.select("id, role")
|
||||
.eq("user_id", DEV_ADMIN_UID)
|
||||
.single();
|
||||
|
||||
if (!existing) {
|
||||
const { error: insertError } = await supabase
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: DEV_ADMIN_UID,
|
||||
brand_id: null,
|
||||
role: "platform_admin",
|
||||
active: true,
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: true,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
can_manage_settings: true,
|
||||
must_change_password: false,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
return { success: false, error: insertError.message };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, uid: DEV_ADMIN_UID };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
|
||||
export async function updatePasswordAction(
|
||||
newPassword: string
|
||||
): Promise<{ error?: string }> {
|
||||
const cookieStore = await cookies();
|
||||
const uid =
|
||||
cookieStore.get("rc_auth_uid")?.value ??
|
||||
cookieStore.get("rc_uid")?.value;
|
||||
|
||||
if (!uid) {
|
||||
return { error: "Not authenticated. Please log in again." };
|
||||
}
|
||||
|
||||
const service = createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
);
|
||||
|
||||
const { error } = await service.rpc("update_user_password", {
|
||||
p_user_id: uid,
|
||||
p_password: newPassword,
|
||||
});
|
||||
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
|
||||
function getServiceClient() {
|
||||
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
|
||||
return createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
roleKey,
|
||||
);
|
||||
}
|
||||
|
||||
export type ResetAdminPasswordResult =
|
||||
| { success: true; tempPassword: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Emergency recovery action — only usable in development or when the caller
|
||||
* already has service role access. Resets the password for the specified email
|
||||
* and returns the temp password so it can be displayed to the user.
|
||||
*/
|
||||
export async function resetAdminPassword(
|
||||
email: string,
|
||||
newPassword: string
|
||||
): Promise<ResetAdminPasswordResult> {
|
||||
const service = getServiceClient();
|
||||
|
||||
// Look up auth user by email
|
||||
const { data: authUsers, error: listError } = await service.auth.admin.listUsers();
|
||||
if (listError || !authUsers?.users) {
|
||||
return { success: false, error: "Could not list users: " + listError?.message };
|
||||
}
|
||||
|
||||
const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (!authUser) {
|
||||
return { success: false, error: "No auth user found for that email address." };
|
||||
}
|
||||
|
||||
// Update password via service role
|
||||
const { error: updateError } = await service.auth.admin.updateUserById(
|
||||
authUser.id,
|
||||
{ password: newPassword, email_confirm: true }
|
||||
);
|
||||
|
||||
if (updateError) {
|
||||
return { success: false, error: updateError.message };
|
||||
}
|
||||
|
||||
return { success: true, tempPassword: newPassword };
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
import { supabase as publicSupabase } from "@/lib/supabase";
|
||||
import { getMockTableData, mockBrands } from "@/lib/mock-data";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type AdminUserRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
email: string;
|
||||
phone_number: string | null;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
|
||||
brand_id: string | null;
|
||||
brand_name: string | null;
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
can_manage_pickup: boolean;
|
||||
can_manage_messages: boolean;
|
||||
can_manage_refunds: boolean;
|
||||
can_manage_users: boolean;
|
||||
can_manage_water_log: boolean;
|
||||
can_manage_reports: boolean;
|
||||
active: boolean;
|
||||
must_change_password: boolean;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
};
|
||||
|
||||
export type CreateAdminUserInput = {
|
||||
email: string;
|
||||
password: string;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||
brand_id: string | null;
|
||||
display_name?: string;
|
||||
phone_number?: string;
|
||||
flags: {
|
||||
can_manage_products?: boolean;
|
||||
can_manage_stops?: boolean;
|
||||
can_manage_orders?: boolean;
|
||||
can_manage_pickup?: boolean;
|
||||
can_manage_messages?: boolean;
|
||||
can_manage_refunds?: boolean;
|
||||
can_manage_users?: boolean;
|
||||
can_manage_water_log?: boolean;
|
||||
can_manage_reports?: boolean;
|
||||
};
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateAdminUserInput = {
|
||||
id: string;
|
||||
role?: "platform_admin" | "brand_admin" | "store_employee";
|
||||
brand_id?: string | null;
|
||||
flags?: Partial<{
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
can_manage_pickup: boolean;
|
||||
can_manage_messages: boolean;
|
||||
can_manage_refunds: boolean;
|
||||
can_manage_users: boolean;
|
||||
can_manage_water_log: boolean;
|
||||
can_manage_reports: boolean;
|
||||
}>;
|
||||
active?: boolean;
|
||||
display_name?: string | null;
|
||||
phone_number?: string | null;
|
||||
};
|
||||
|
||||
// ─── SSR client for authenticated requests ─────────────────────────────────
|
||||
|
||||
async function getAuthClient() {
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
// Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
|
||||
// cookies that arrive in the header but NOT in next/headers cookies()).
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const allCookies = cookieHeader.split(";").map(c => c.trim());
|
||||
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
|
||||
const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null;
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll(); },
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
|
||||
Object.entries(headers).forEach(([key, value]) => response.headers.set(key, value));
|
||||
},
|
||||
},
|
||||
});
|
||||
return { supabase, response, rcAuthUid };
|
||||
}
|
||||
|
||||
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
|
||||
const { supabase, rcAuthUid } = await getAuthClient();
|
||||
|
||||
// Dev force-login UID bypasses Supabase auth entirely
|
||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
return { data: null, error: null }; // let the action proceed without auth check
|
||||
}
|
||||
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
if (userError || !userData.user) {
|
||||
return { data: null, error: "Not authenticated" };
|
||||
}
|
||||
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
|
||||
if (error) { /* RPC error handled silently */ }
|
||||
return { data: data as T, error: error ? error.message : null };
|
||||
}
|
||||
|
||||
// ─── Service role client (server-only, never exposed to browser) ───────────
|
||||
|
||||
function getServiceClient() {
|
||||
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!roleKey) {
|
||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path.");
|
||||
}
|
||||
return createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
roleKey,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dev-only path — uses service role to create auth user + admin_users ──
|
||||
|
||||
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return { user: null, error: "Dev path not available in production" };
|
||||
}
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
if (!devSession || devSession !== "platform_admin") {
|
||||
return { user: null, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
const service = getServiceClient();
|
||||
|
||||
// Create auth user with the provided password
|
||||
const { data: authUser, error: authError } = await service.auth.admin.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
},
|
||||
});
|
||||
if (authError || !authUser.user) {
|
||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||
}
|
||||
|
||||
// Insert into admin_users
|
||||
const { data: inserted, error: insertError } = await service
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: authUser.user.id,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
return { user: null, error: insertError.message };
|
||||
}
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.email,
|
||||
name: input.display_name || input.email.split("@")[0],
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch (e) {
|
||||
// welcome email failed silently
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: inserted.id,
|
||||
user_id: inserted.user_id,
|
||||
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: inserted.phone_number ?? input.phone_number ?? null,
|
||||
role: inserted.role,
|
||||
brand_id: inserted.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: inserted.can_manage_products,
|
||||
can_manage_stops: inserted.can_manage_stops,
|
||||
can_manage_orders: inserted.can_manage_orders,
|
||||
can_manage_pickup: inserted.can_manage_pickup,
|
||||
can_manage_messages: inserted.can_manage_messages,
|
||||
can_manage_refunds: inserted.can_manage_refunds,
|
||||
can_manage_users: inserted.can_manage_users,
|
||||
can_manage_water_log: inserted.can_manage_water_log,
|
||||
can_manage_reports: inserted.can_manage_reports,
|
||||
active: inserted.active,
|
||||
must_change_password: inserted.must_change_password ?? true,
|
||||
created_at: inserted.created_at,
|
||||
last_login: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
user_id: String(row.user_id ?? ""),
|
||||
display_name: (row.display_name as string | null) ?? null,
|
||||
email: String(row.email ?? ""),
|
||||
phone_number: (row.phone_number as string | null) ?? null,
|
||||
role: (row.role as AdminUserRow["role"]) ?? "store_employee",
|
||||
brand_id: (row.brand_id as string | null) ?? null,
|
||||
brand_name: (row.brand_name as string | null) ?? null,
|
||||
can_manage_products: Boolean(row.can_manage_products ?? false),
|
||||
can_manage_stops: Boolean(row.can_manage_stops ?? false),
|
||||
can_manage_orders: Boolean(row.can_manage_orders ?? false),
|
||||
can_manage_pickup: Boolean(row.can_manage_pickup ?? false),
|
||||
can_manage_messages: Boolean(row.can_manage_messages ?? false),
|
||||
can_manage_refunds: Boolean(row.can_manage_refunds ?? false),
|
||||
can_manage_users: Boolean(row.can_manage_users ?? false),
|
||||
can_manage_water_log: Boolean(row.can_manage_water_log ?? false),
|
||||
can_manage_reports: Boolean(row.can_manage_reports ?? false),
|
||||
active: Boolean(row.active ?? true),
|
||||
must_change_password: Boolean(row.must_change_password ?? false),
|
||||
created_at: String(row.created_at ?? ""),
|
||||
last_login: (row.last_login as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dev path helpers (service role, local only) ───────────────────────────
|
||||
|
||||
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
const service = getServiceClient();
|
||||
|
||||
// Ensure caller has an admin_users record
|
||||
if (callerUid) {
|
||||
const { data: existing } = await service
|
||||
.from("admin_users")
|
||||
.select("id")
|
||||
.eq("user_id", callerUid)
|
||||
.maybeSingle();
|
||||
|
||||
if (!existing) {
|
||||
// auto-creating admin_users for uid
|
||||
const { data: authData } = await service.auth.admin.listUsers();
|
||||
const authUser = authData?.users?.find((u) => u.id === callerUid);
|
||||
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
|
||||
await service.from("admin_users").insert({
|
||||
user_id: callerUid,
|
||||
role: "platform_admin",
|
||||
brand_id: null,
|
||||
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin",
|
||||
phone_number: (meta?.phone_number as string | null) ?? null,
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: true,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
active: true,
|
||||
must_change_password: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all admin_users rows (no RLS for service role)
|
||||
const { data: adminRows, error: adminError } = await service
|
||||
.from("admin_users")
|
||||
.select(`
|
||||
id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
brands (name)
|
||||
`)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (adminError) return { users: [], error: adminError.message };
|
||||
|
||||
// Fetch auth user details via service role admin API
|
||||
const { data: authData, error: authError } = await service.auth.admin.listUsers();
|
||||
if (authError) return { users: [], error: authError.message };
|
||||
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authData?.users ?? []).forEach((u) => {
|
||||
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
|
||||
authMap[user.id] = {
|
||||
email: user.email ?? "",
|
||||
display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null,
|
||||
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return { users, error: null };
|
||||
}
|
||||
|
||||
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authUsers ?? []).forEach((u) => {
|
||||
authMap[u.id] = {
|
||||
email: u.email ?? "",
|
||||
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null,
|
||||
phone_number: (u.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const users: AdminUserRow[] = adminRows.map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return { users, error: null };
|
||||
}
|
||||
|
||||
// ─── Production admin actions (require real Supabase auth) ─────────────────
|
||||
|
||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
|
||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
let filteredUsers = mockUsers;
|
||||
if (brandId) {
|
||||
filteredUsers = mockUsers.filter(u => u.brand_id === brandId);
|
||||
}
|
||||
return { users: filteredUsers, error: null };
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
|
||||
// Read rc_auth_uid for force-login check
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
|
||||
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
|
||||
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
|
||||
return devListAdminUsers(rcAuthUid);
|
||||
}
|
||||
|
||||
// Dev session cookie (platform_admin/brand_admin) — always use service role path
|
||||
const isDevAdmin = process.env.NODE_ENV !== "production" && (
|
||||
devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
|
||||
);
|
||||
if (isDevAdmin) {
|
||||
return devListAdminUsers(rcAuthUid ?? undefined);
|
||||
}
|
||||
|
||||
// Production path: try authenticated RPC first, fall back to service role if not authenticated
|
||||
const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null });
|
||||
if (result.error === "Not authenticated" && rcAuthUid) {
|
||||
// No Supabase session token in browser — use service role with rc_auth_uid
|
||||
const service = getServiceClient();
|
||||
const { data: adminRows, error: adminError } = await service
|
||||
.from("admin_users")
|
||||
.select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
brands (name)`)
|
||||
.order("created_at", { ascending: false });
|
||||
if (adminError) return { users: [], error: adminError.message };
|
||||
const { data: authData } = await service.auth.admin.listUsers();
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authData?.users ?? []).forEach((u) => {
|
||||
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
|
||||
authMap[user.id] = {
|
||||
email: user.email ?? "",
|
||||
display_name: (user.user_metadata?.display_name as string | null) ?? null,
|
||||
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
return { users, error: null };
|
||||
}
|
||||
return { users: result.data ?? [], error: result.error };
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Read auth context
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
return devCreateAdminUser(input);
|
||||
}
|
||||
|
||||
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
|
||||
|
||||
// Dev path: use service role to create user without Supabase auth session
|
||||
if (isDevAdmin) {
|
||||
return devCreateAdminUser(input);
|
||||
}
|
||||
|
||||
// Production path — use service role directly (bypasses Supabase JWT auth)
|
||||
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
|
||||
if (rcAuthUid) {
|
||||
const service = getServiceClient();
|
||||
|
||||
// Create auth user
|
||||
const { data: authUser, error: authError } = await service.auth.admin.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
},
|
||||
});
|
||||
if (authError || !authUser.user) {
|
||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||
}
|
||||
|
||||
// Insert into admin_users
|
||||
const { data: inserted, error: insertError } = await service
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: authUser.user.id,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) return { user: null, error: insertError.message };
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.email,
|
||||
name: input.display_name || input.email.split("@")[0],
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch (e) {
|
||||
// welcome email failed silently
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: inserted.id,
|
||||
user_id: inserted.user_id,
|
||||
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: inserted.phone_number ?? input.phone_number ?? null,
|
||||
role: inserted.role,
|
||||
brand_id: inserted.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: inserted.can_manage_products,
|
||||
can_manage_stops: inserted.can_manage_stops,
|
||||
can_manage_orders: inserted.can_manage_orders,
|
||||
can_manage_pickup: inserted.can_manage_pickup,
|
||||
can_manage_messages: inserted.can_manage_messages,
|
||||
can_manage_refunds: inserted.can_manage_refunds,
|
||||
can_manage_users: inserted.can_manage_users,
|
||||
can_manage_water_log: inserted.can_manage_water_log,
|
||||
can_manage_reports: inserted.can_manage_reports,
|
||||
active: inserted.active,
|
||||
must_change_password: inserted.must_change_password ?? true,
|
||||
created_at: inserted.created_at,
|
||||
last_login: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { user: null, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
const service = getServiceClient();
|
||||
const { data, error } = await service
|
||||
.from("admin_users")
|
||||
.update({
|
||||
role: input.role ?? undefined,
|
||||
brand_id: input.brand_id ?? undefined,
|
||||
can_manage_products: input.flags?.can_manage_products ?? undefined,
|
||||
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
|
||||
can_manage_orders: input.flags?.can_manage_orders ?? undefined,
|
||||
can_manage_pickup: input.flags?.can_manage_pickup ?? undefined,
|
||||
can_manage_messages: input.flags?.can_manage_messages ?? undefined,
|
||||
can_manage_refunds: input.flags?.can_manage_refunds ?? undefined,
|
||||
can_manage_users: input.flags?.can_manage_users ?? undefined,
|
||||
can_manage_water_log: input.flags?.can_manage_water_log ?? undefined,
|
||||
can_manage_reports: input.flags?.can_manage_reports ?? undefined,
|
||||
active: input.active ?? undefined,
|
||||
display_name: input.display_name ?? null,
|
||||
phone_number: input.phone_number ?? null,
|
||||
})
|
||||
.eq("id", input.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { user: null, error: error.message };
|
||||
return { user: mapUserRow(data), error: null };
|
||||
}
|
||||
|
||||
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
|
||||
p_id: input.id,
|
||||
p_role: input.role ?? null,
|
||||
p_brand_id: input.brand_id ?? null,
|
||||
p_flags: input.flags ?? null,
|
||||
p_active: input.active ?? null,
|
||||
p_display_name: input.display_name ?? null,
|
||||
p_phone_number: input.phone_number ?? null,
|
||||
});
|
||||
const rows = result.data as AdminUserRow[] | null;
|
||||
return { user: rows?.[0] ?? null, error: result.error };
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
const service = getServiceClient();
|
||||
// Get user_id first
|
||||
const { data: adminRow, error: fetchError } = await service
|
||||
.from("admin_users")
|
||||
.select("user_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
if (fetchError) return { success: false, error: fetchError.message };
|
||||
// Delete from admin_users
|
||||
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id);
|
||||
if (deleteError) return { success: false, error: deleteError.message };
|
||||
// Delete auth user
|
||||
if (adminRow?.user_id) {
|
||||
await service.auth.admin.deleteUser(adminRow.user_id);
|
||||
}
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
|
||||
return { success: result.data ?? false, error: result.error };
|
||||
}
|
||||
|
||||
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
|
||||
const service = getServiceClient();
|
||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
}
|
||||
|
||||
// Production path — use service role via direct update
|
||||
const service = getServiceClient();
|
||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
|
||||
});
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
}
|
||||
|
||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
|
||||
return { brands, error: null };
|
||||
}
|
||||
|
||||
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name");
|
||||
return { brands: data ?? [], error: error?.message ?? null };
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
|
||||
import { importProductsBatch } from "@/actions/import-products";
|
||||
import { importOrdersBatch } from "@/actions/import-orders";
|
||||
import { createStopsBatch } from "@/actions/stops";
|
||||
import { importContactsBatch } from "@/actions/communications/contacts";
|
||||
|
||||
export type ImportEntityType = "products" | "orders" | "contacts" | "stops" | "unknown";
|
||||
|
||||
export type ColumnMapping = Record<string, string>; // header → semantic field
|
||||
|
||||
export type ImportAnalysis = {
|
||||
detectedType: ImportEntityType;
|
||||
confidence: number;
|
||||
columnMappings: ColumnMapping;
|
||||
cleanedRows: Record<string, unknown>[];
|
||||
rawRows: string[][];
|
||||
headers: string[];
|
||||
rowCount: number;
|
||||
warnings: string[];
|
||||
autoFixApplied: string[];
|
||||
};
|
||||
|
||||
export type AnalyzeImportResult =
|
||||
| { success: true; analysis: ImportAnalysis }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type ExecuteImportResult =
|
||||
| { success: true; created: number; updated: number; errors: { row: number; message: string }[] }
|
||||
| { success: true; imported: number; errors: { row: number; message: string }[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── Analyze ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function analyzeImport(
|
||||
base64Data: string,
|
||||
fileName: string,
|
||||
brandId: string
|
||||
): Promise<AnalyzeImportResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
// Decode file
|
||||
let rawText: string;
|
||||
try {
|
||||
const binaryStr = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
rawText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
} catch {
|
||||
return { success: false, error: "Could not decode file" };
|
||||
}
|
||||
|
||||
// Parse based on file type
|
||||
let headers: string[];
|
||||
let rows: string[][];
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (["xlsx", "xls"].includes(ext)) {
|
||||
const binaryStr = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
const buf = Buffer.from(bytes);
|
||||
const parsed = await parseExcelBuffer(buf);
|
||||
headers = parsed.headers;
|
||||
rows = parsed.rows;
|
||||
} else {
|
||||
const parsed = parseTextBuffer(rawText);
|
||||
headers = parsed.headers;
|
||||
rows = parsed.rows;
|
||||
}
|
||||
|
||||
if (headers.length === 0 || rows.length === 0) {
|
||||
return { success: false, error: "File appears to be empty" };
|
||||
}
|
||||
|
||||
if (rows.length > 5000) {
|
||||
return { success: false, error: "File too large. Max 5,000 rows." };
|
||||
}
|
||||
|
||||
// Call AI to analyze
|
||||
const analysis = await callAIAnalysis(headers, rows, brandId);
|
||||
return { success: true, analysis };
|
||||
}
|
||||
|
||||
// ── Execute ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeImport(
|
||||
brandId: string,
|
||||
detectedType: ImportEntityType,
|
||||
rows: Record<string, unknown>[]
|
||||
): Promise<ExecuteImportResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
switch (detectedType) {
|
||||
case "products":
|
||||
return executeProductsImport(brandId, rows);
|
||||
case "orders":
|
||||
return executeOrdersImport(brandId, rows);
|
||||
case "stops":
|
||||
return executeStopsImport(brandId, rows);
|
||||
case "contacts":
|
||||
return executeContactsImport(brandId, rows);
|
||||
default:
|
||||
return { success: false, error: `Import type "${detectedType}" not yet supported in Import Center` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI Core ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function callAIAnalysis(
|
||||
headers: string[],
|
||||
rows: string[][],
|
||||
brandId: string
|
||||
): Promise<ImportAnalysis> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
// Build sample rows (first 30 for token economy)
|
||||
const sampleRows = rows.slice(0, 30);
|
||||
const sampleText = [headers.join(","), ...sampleRows.map((r) => r.join(","))].join("\n");
|
||||
|
||||
// Non-AI fallback parser for comparison
|
||||
const { columnMappings: fallbackMappings, cleanedRows: fallbackRows, warnings } = fallbackParse(headers, rows);
|
||||
|
||||
if (!apiKey) {
|
||||
// No AI key — use rule-based fallback
|
||||
return {
|
||||
detectedType: fallbackMappings["__entity_type"] as ImportEntityType ?? "unknown",
|
||||
confidence: 0.5,
|
||||
columnMappings: fallbackMappings,
|
||||
cleanedRows: fallbackRows,
|
||||
rawRows: rows,
|
||||
headers,
|
||||
rowCount: rows.length,
|
||||
warnings,
|
||||
autoFixApplied: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Compose AI prompt
|
||||
const systemPrompt = `You are a data import analyst for a B2B produce wholesale platform called Route Commerce.
|
||||
Given CSV-like data with headers and sample rows, respond ONLY with valid JSON (no markdown, no explanation):
|
||||
{
|
||||
"detectedType": "products" | "orders" | "contacts" | "stops" | "unknown",
|
||||
"confidence": 0.0-1.0,
|
||||
"columnMappings": { "Header Name": "semantic_field", ... },
|
||||
"cleanedRows": [ { "field": "value", ... }, ... ],
|
||||
"warnings": ["row N: issue description", ...],
|
||||
"autoFixApplied": ["description of fixes applied", ...]
|
||||
}
|
||||
|
||||
Semantic fields:
|
||||
- products: product_name, price, description, product_type (Pickup|Shipping|Pickup & Shipping), active (true|false), image_url
|
||||
- orders: customer_name, customer_email, customer_phone, stop_id, product_name (or product_id), quantity, fulfillment (Pickup|Shipping)
|
||||
- contacts: email, phone, first_name, last_name, full_name, tags, email_opt_in (true|false), sms_opt_in (true|false), external_id
|
||||
- stops: city, state, location (business name or address), date (YYYY-MM-DD), time (HH:MM), address, zip, notes
|
||||
|
||||
Rules:
|
||||
- Normalize phone numbers to (XXX) XXX-XXXX format
|
||||
- Normalize email to lowercase
|
||||
- Trim whitespace from all values
|
||||
- For prices: keep as number string, flag non-numeric
|
||||
- For dates: normalize to YYYY-MM-DD
|
||||
- Set confidence 0.9+ only if clear match
|
||||
- Map columns by HEADER NAME (exact key in columnMappings), not by position
|
||||
- cleanedRows: return max 50 sample rows — full import uses the same mapping
|
||||
- If ambiguous columns exist (e.g. two "email" candidates), pick the one with more populated values
|
||||
- Do NOT add __entity_type to cleanedRows — only in columnMappings output`;
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`OpenAI error: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
const parsed = JSON.parse(data.choices[0].message.content as string);
|
||||
|
||||
return {
|
||||
detectedType: parsed.detectedType ?? "unknown",
|
||||
confidence: parsed.confidence ?? 0.5,
|
||||
columnMappings: parsed.columnMappings ?? {},
|
||||
cleanedRows: parsed.cleanedRows ?? fallbackRows,
|
||||
rawRows: rows,
|
||||
headers,
|
||||
rowCount: rows.length,
|
||||
warnings: parsed.warnings ?? [],
|
||||
autoFixApplied: parsed.autoFixApplied ?? [],
|
||||
};
|
||||
} catch (err) {
|
||||
// Fall back to rule-based parsing
|
||||
return {
|
||||
detectedType: fallbackMappings["__entity_type"] as ImportEntityType ?? "unknown",
|
||||
confidence: 0.4,
|
||||
columnMappings: fallbackMappings,
|
||||
cleanedRows: fallbackRows,
|
||||
rawRows: rows,
|
||||
headers,
|
||||
rowCount: rows.length,
|
||||
warnings: [`AI parse failed, using rule-based fallback: ${String(err)}`],
|
||||
autoFixApplied: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule-Based Fallback Parser ────────────────────────────────────────────────
|
||||
|
||||
function fallbackParse(
|
||||
headers: string[],
|
||||
rows: string[][]
|
||||
): { columnMappings: ColumnMapping; cleanedRows: Record<string, unknown>[]; warnings: string[] } {
|
||||
const h = headers.map((h) => h.toLowerCase().trim());
|
||||
|
||||
// Score each header for each entity type
|
||||
const typeScores: Record<string, number> = { products: 0, orders: 0, contacts: 0, stops: 0 };
|
||||
|
||||
const productKeywords = ["name", "product", "price", "cost", "description", "type", "active", "image"];
|
||||
const orderKeywords = ["customer", "email", "phone", "order", "stop", "product", "quantity", "fulfillment", "item"];
|
||||
const contactKeywords = ["contact", "first", "last", "name", "email", "phone", "opt", "sms", "tag", "external"];
|
||||
const stopKeywords = ["city", "state", "location", "address", "date", "time", "pickup", "stop", "zip", "notes"];
|
||||
|
||||
const keywordMaps: Record<string, string[]> = {
|
||||
products: productKeywords,
|
||||
orders: orderKeywords,
|
||||
contacts: contactKeywords,
|
||||
stops: stopKeywords,
|
||||
};
|
||||
|
||||
for (const header of h) {
|
||||
for (const [etype, keywords] of Object.entries(keywordMaps)) {
|
||||
for (const kw of keywords) {
|
||||
if (header.includes(kw)) typeScores[etype] = (typeScores[etype] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const detectedType = (Object.entries(typeScores).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown") as ImportEntityType;
|
||||
|
||||
// Map headers to semantic fields
|
||||
const columnMappings: ColumnMapping = {};
|
||||
const semanticMap: Record<string, string[]> = {
|
||||
product_name: ["product_name", "name", "product", "item", "goods_name", "item_name", "item name", "title", "product name", "merchandise"],
|
||||
price: ["price", "cost", "retail_price", "unit_price", "sale_price", "amount", "product_price"],
|
||||
description: ["description", "desc", "product_desc", "long_description", "details", "product_description"],
|
||||
product_type: ["product_type", "type", "category", "product_category", "fulfillment_type", "fulfillment", "shipping_type", "delivery_type"],
|
||||
active: ["active", "available", "in_stock", "is_active", "enabled", "published", "is_available"],
|
||||
image_url: ["image_url", "image", "photo", "picture", "img_url", "product_image", "pic"],
|
||||
customer_name: ["customer_name", "name", "customer", "buyer_name", "ordered_by", "purchaser", "buyer"],
|
||||
customer_email: ["customer_email", "email", "buyer_email", "e-mail", "email_address", "customer e-mail"],
|
||||
customer_phone: ["customer_phone", "phone", "telephone", "mobile", "cell", "phone_number", "contact", "tel"],
|
||||
stop_id: ["stop_id", "stop", "pickup_location", "location_id", "stop id", "route_id", "schedule_id"],
|
||||
quantity: ["quantity", "qty", "amount", "count", "units", "number_of_items", "item_count"],
|
||||
fulfillment: ["fulfillment", "fulfillment_type", "delivery_method", "ship_or_pickup", "shipping_method", "fulfill", "delivery", "shipping"],
|
||||
product_id: ["product_id", "item_id", "sku", "item_number", "product id", "item number"],
|
||||
first_name: ["first_name", "first", "firstName", "fname"],
|
||||
last_name: ["last_name", "last", "lastName", "lname"],
|
||||
full_name: ["full_name", "name", "customer_name"],
|
||||
tags: ["tags", "tag", "segments", "segment", "labels"],
|
||||
email_opt_in: ["email_opt_in", "opt_in_email", "email_opt", "emailoptin"],
|
||||
sms_opt_in: ["sms_opt_in", "opt_in_sms", "sms_opt", "smsoptin"],
|
||||
external_id: ["external_id", "externalid", "id", "reference", "external_id"],
|
||||
city: ["city", "town", "locality"],
|
||||
state: ["state", "st", "province"],
|
||||
location: ["location", "pickup_location", "venue", "place", "business", "name", "location_name"],
|
||||
date: ["date", "pickup_date", "delivery_date", "stop_date", "date_of_stop"],
|
||||
time: ["time", "pickup_time", "start_time", "window", "hours", "pickup_hours"],
|
||||
address: ["address", "street_address", "street", "addr"],
|
||||
zip: ["zip", "zipcode", "postal_code", "postal", "zip_code"],
|
||||
notes: ["notes", "note", "special_instructions", "comments", "memo", "instruction"],
|
||||
};
|
||||
|
||||
for (let i = 0; i < h.length; i++) {
|
||||
const header = h[i];
|
||||
for (const [field, keywords] of Object.entries(semanticMap)) {
|
||||
if (keywords.some((kw) => header.includes(kw))) {
|
||||
columnMappings[headers[i]] = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
columnMappings["__entity_type"] = detectedType;
|
||||
|
||||
// Build cleaned rows
|
||||
const cleanedRows = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const header = headers[i];
|
||||
const field = columnMappings[header];
|
||||
if (field && field !== "__entity_type") {
|
||||
let val = row[i]?.trim() ?? "";
|
||||
// Normalize
|
||||
if (field === "price") val = val.replace(/[^0-9.]/g, "");
|
||||
if (field === "email_opt_in" || field === "sms_opt_in") val = val.toLowerCase();
|
||||
obj[field] = val;
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
|
||||
return { columnMappings, cleanedRows, warnings: [] };
|
||||
}
|
||||
|
||||
// ── Import Executors ─────────────────────────────────────────────────────────
|
||||
|
||||
async function executeProductsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||
const products = rows.map((r) => ({
|
||||
name: String(r.product_name ?? r.name ?? ""),
|
||||
description: String(r.description ?? ""),
|
||||
price: parseFloat(String(r.price ?? "0").replace(/[^0-9.]/g, "")) || 0,
|
||||
type: normalizeFulfillmentType(String(r.product_type ?? r.type ?? "Pickup")),
|
||||
active: String(r.active ?? "true").toLowerCase() !== "false",
|
||||
image_url: r.image_url ? String(r.image_url) : undefined,
|
||||
})).filter((p) => p.name !== "");
|
||||
|
||||
const result = await importProductsBatch(brandId, products);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error } as ExecuteImportResult;
|
||||
}
|
||||
return {
|
||||
success: true as const,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
errors: result.errors.map((e) => ({ row: 0, message: e.error })),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeOrdersImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||
// Group rows by customer+stop (merge multi-item rows)
|
||||
const orderMap: Record<string, { customer_name: string; customer_email: string; customer_phone: string; stop_id: string; items: { product_id: string; quantity: number; fulfillment: string }[] }> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.customer_email}_${row.stop_id}`;
|
||||
if (!orderMap[key]) {
|
||||
orderMap[key] = {
|
||||
customer_name: String(row.customer_name ?? ""),
|
||||
customer_email: String(row.customer_email ?? ""),
|
||||
customer_phone: String(row.customer_phone ?? ""),
|
||||
stop_id: String(row.stop_id ?? ""),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
if (row.product_id || row.product_name) {
|
||||
orderMap[key].items.push({
|
||||
product_id: String(row.product_id ?? ""),
|
||||
quantity: parseInt(String(row.quantity ?? "1")) || 1,
|
||||
fulfillment: normalizeFulfillmentType(String(row.fulfillment ?? "Pickup")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const orders = Object.values(orderMap)
|
||||
.filter((o) => o.customer_email && o.stop_id)
|
||||
.map((o) => ({
|
||||
customer_name: o.customer_name || "",
|
||||
customer_email: o.customer_email,
|
||||
customer_phone: o.customer_phone || "",
|
||||
stop_id: o.stop_id,
|
||||
items: o.items,
|
||||
}));
|
||||
|
||||
return importOrdersBatch(brandId, orders).then((r) => {
|
||||
if (r.success) {
|
||||
return {
|
||||
success: true as const,
|
||||
imported: r.imported as number,
|
||||
errors: (r.errors as Array<{ row: number; error: string }>).map((e) => ({ row: e.row ?? 0, message: e.error ?? String(e) })),
|
||||
};
|
||||
}
|
||||
return { success: false, error: r.error as string };
|
||||
}) as Promise<ExecuteImportResult>;
|
||||
}
|
||||
|
||||
async function executeStopsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||
const stops = rows.map((r) => ({
|
||||
city: String(r.city ?? ""),
|
||||
state: String(r.state ?? ""),
|
||||
location: String(r.location ?? r.address ?? ""),
|
||||
date: normalizeDate(String(r.date ?? "")),
|
||||
time: String(r.time ?? ""),
|
||||
address: r.address ? String(r.address) : undefined,
|
||||
zip: r.zip ? String(r.zip) : undefined,
|
||||
notes: r.notes ? String(r.notes) : undefined,
|
||||
})).filter((s) => s.city !== "" && s.state !== "");
|
||||
|
||||
return createStopsBatch(brandId, stops).then((r) => {
|
||||
if (r.success) {
|
||||
return { success: true as const, created: r.created as number, updated: 0, errors: [] };
|
||||
}
|
||||
return { success: false, error: r.error as string };
|
||||
}) as Promise<ExecuteImportResult>;
|
||||
}
|
||||
|
||||
async function executeContactsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||
const contacts = rows.map((r) => ({
|
||||
email: r.email ? String(r.email).toLowerCase().trim() : undefined,
|
||||
phone: r.phone ? String(r.phone).trim() : undefined,
|
||||
first_name: r.first_name ? String(r.first_name).trim() : undefined,
|
||||
last_name: r.last_name ? String(r.last_name).trim() : undefined,
|
||||
full_name: r.full_name ? String(r.full_name).trim() : undefined,
|
||||
tags: r.tags ? String(r.tags).split(",").map((t: string) => t.trim()) : [],
|
||||
email_opt_in: r.email_opt_in !== undefined ? String(r.email_opt_in).toLowerCase() === "true" : true,
|
||||
sms_opt_in: r.sms_opt_in !== undefined ? String(r.sms_opt_in).toLowerCase() === "true" : false,
|
||||
external_id: r.external_id ? String(r.external_id).trim() : undefined,
|
||||
})).filter((c) => c.email || c.phone);
|
||||
|
||||
const result = await importContactsBatch({ brandId, contacts });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error } as ExecuteImportResult;
|
||||
}
|
||||
return {
|
||||
success: true as const,
|
||||
created: result.result.created,
|
||||
updated: result.result.updated,
|
||||
errors: result.result.errors.map((e) => ({ row: 0, message: e.error ?? "Unknown error" })),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Normalizers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeFulfillmentType(t: string): string {
|
||||
const lower = t.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (lower.includes("pickup") && lower.includes("ship")) return "Pickup & Shipping";
|
||||
if (lower.includes("ship")) return "Shipping";
|
||||
return "Pickup";
|
||||
}
|
||||
|
||||
function normalizeDate(dateStr: string): string {
|
||||
if (!dateStr) return "";
|
||||
// Try common formats
|
||||
const d = new Date(dateStr);
|
||||
if (!isNaN(d.getTime())) return d.toISOString().split("T")[0];
|
||||
// Try MM/DD/YYYY
|
||||
const parts = dateStr.split(/[\/\-]/);
|
||||
if (parts.length === 3) {
|
||||
const [, m, d2] = parts;
|
||||
if (m && d2) {
|
||||
const normalized = new Date(`${parts[2]}-${m.padStart(2, "0")}-${d2.padStart(2, "0")}`);
|
||||
if (!isNaN(normalized.getTime())) return normalized.toISOString().split("T")[0];
|
||||
}
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
|
||||
|
||||
export type AuditPayload = {
|
||||
table_name: string;
|
||||
record_id: string;
|
||||
action: AuditAction;
|
||||
old_data?: Record<string, unknown> | null;
|
||||
new_data?: Record<string, unknown> | null;
|
||||
brand_id?: string | null;
|
||||
};
|
||||
|
||||
type AuditResult =
|
||||
| { success: true; audit_id: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Logs an audit event to the audit_logs table.
|
||||
*
|
||||
* In dev mode (dev_session cookie), uses the dev user identity.
|
||||
* In production (Supabase auth), resolves the admin user from admin_users.
|
||||
*
|
||||
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
||||
*/
|
||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
const performed_by = adminUser?.user_id ?? null;
|
||||
const performed_by_email =
|
||||
adminUser?.user_id === "dev-user-00000000-0000-0000-0000-000000000000"
|
||||
? "dev@platform-admin"
|
||||
: adminUser?.user_id
|
||||
? `admin_user:${adminUser.user_id}`
|
||||
: null;
|
||||
|
||||
const rpcPayload = {
|
||||
table_name: payload.table_name,
|
||||
record_id: payload.record_id,
|
||||
action: payload.action,
|
||||
old_data: payload.old_data ?? null,
|
||||
new_data: payload.new_data ?? null,
|
||||
performed_by,
|
||||
performed_by_email,
|
||||
brand_id: payload.brand_id ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/log_audit_event`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({ p_payload: rpcPayload }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: "Unknown error" }));
|
||||
return { success: false, error: err.message ?? "Failed to write audit log" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const auditId = typeof data === "string" ? data : data?.[0]?.id ?? data?.id;
|
||||
|
||||
if (!auditId) {
|
||||
return { success: false, error: "Audit log written but ID not returned" };
|
||||
}
|
||||
|
||||
return { success: true, audit_id: auditId };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use server";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
type LineItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export async function createRetailStripeCheckoutSession(
|
||||
items: LineItem[],
|
||||
orderId: string,
|
||||
brandId: string,
|
||||
successUrl: string,
|
||||
cancelUrl: string
|
||||
): Promise<{ success: boolean; url?: string; sessionId?: string; error?: string }> {
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
|
||||
const lineItems = items.map((item) => ({
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: { name: item.name },
|
||||
unit_amount: Math.round(item.price * 100), // stripe uses cents
|
||||
},
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
// Get brand name for Stripe metadata
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
let brandName = "Route Commerce";
|
||||
try {
|
||||
const brandRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
);
|
||||
const brands = await brandRes.json() as Array<{ name: string }>;
|
||||
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
line_items: lineItems,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: {
|
||||
order_id: orderId,
|
||||
brand_id: brandId,
|
||||
brand_name: brandName,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, url: session.url ?? undefined, sessionId: session.id };
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Price ID config ────────────────────────────────────────────────────────────
|
||||
// Maps plan/addon keys to Stripe price IDs via environment variables
|
||||
|
||||
const PRICE_KEYS: Record<string, string | undefined> = {
|
||||
starter: process.env.STRIPE_PRICE_STARTER,
|
||||
farm: process.env.STRIPE_PRICE_FARM,
|
||||
enterprise: process.env.STRIPE_PRICE_ENTERPRISE,
|
||||
harvest_reach: process.env.STRIPE_PRICE_HARVEST_REACH,
|
||||
wholesale_portal: process.env.STRIPE_PRICE_WHOLESALE_PORTAL,
|
||||
water_log: process.env.STRIPE_PRICE_WATER_LOG,
|
||||
ai_tools: process.env.STRIPE_PRICE_AI_TOOLS,
|
||||
square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC,
|
||||
sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS,
|
||||
};
|
||||
|
||||
function getPriceId(key: string): string | null {
|
||||
return PRICE_KEYS[key] ?? null;
|
||||
}
|
||||
|
||||
// ── Checkout session creation ─────────────────────────────────────────────────
|
||||
|
||||
export async function createStripeCheckoutSession(
|
||||
brandId: string,
|
||||
priceKey: string,
|
||||
successPath: string,
|
||||
cancelPath: string,
|
||||
annual = false
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) return { success: false, error: "Stripe not configured" };
|
||||
|
||||
const priceId = getPriceId(priceKey);
|
||||
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get brand's Stripe customer ID
|
||||
const custRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
);
|
||||
const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>;
|
||||
const brand = brands[0];
|
||||
if (!brand?.stripe_customer_id) {
|
||||
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||
|
||||
// Build recurring interval
|
||||
const recurring = annual ? { interval: "year" } : { interval: "month" };
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: brand.stripe_customer_id!,
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${siteUrl}${successPath}?session_id={CHECKOUT_SESSION_ID}&status=success`,
|
||||
cancel_url: `${siteUrl}${cancelPath}?status=cancelled`,
|
||||
metadata: { brand_id: brandId, price_key: priceKey, billing: annual ? "annual" : "monthly" },
|
||||
subscription_data: {
|
||||
metadata: { brand_id: brandId, price_key: priceKey },
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, url: session.url ?? undefined };
|
||||
}
|
||||
|
||||
export async function createPlanUpgradeCheckout(
|
||||
brandId: string,
|
||||
planTier: string
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
if (!["starter", "farm", "enterprise"].includes(planTier)) {
|
||||
return { success: false, error: "Invalid plan tier" };
|
||||
}
|
||||
return createStripeCheckoutSession(
|
||||
brandId,
|
||||
planTier,
|
||||
"/admin/settings/billing",
|
||||
"/admin/settings/billing"
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAddonCheckoutSession(
|
||||
brandId: string,
|
||||
addonKey: string
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
return createStripeCheckoutSession(
|
||||
brandId,
|
||||
addonKey,
|
||||
"/admin/settings/billing",
|
||||
"/admin/settings/billing"
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelAddonSubscription(
|
||||
brandId: string,
|
||||
addonKey: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) return { success: false, error: "Stripe not configured" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get active subscription for this brand
|
||||
const subRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!subRes.ok) return { success: false, error: "Failed to get subscription" };
|
||||
const subData = await subRes.json() as { stripe_subscription_id?: string };
|
||||
if (!subData?.stripe_subscription_id) {
|
||||
return { success: false, error: "No active subscription found" };
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
|
||||
// Retrieve subscription and find the item for this add-on
|
||||
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
||||
const targetPriceId = getPriceId(addonKey);
|
||||
|
||||
const itemToDelete = subscription.items.data.find(
|
||||
(item) => item.price.id === targetPriceId
|
||||
);
|
||||
|
||||
if (!itemToDelete) {
|
||||
return { success: false, error: "Add-on subscription item not found in Stripe" };
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(subData.stripe_subscription_id, {
|
||||
items: [{ id: itemToDelete.id, deleted: true }],
|
||||
proration_behavior: "none",
|
||||
});
|
||||
|
||||
// Disable the feature flag locally
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }),
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) return { success: false, error: "Stripe not configured" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get stripe_customer_id from brands table
|
||||
const custRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`,
|
||||
{ headers: svcHeaders(supabaseKey) }
|
||||
);
|
||||
const custData = await custRes.json();
|
||||
const stripeCustomerId = custData?.[0]?.stripe_customer_id;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing`,
|
||||
});
|
||||
|
||||
return { success: true, url: session.url };
|
||||
}
|
||||
|
||||
export async function updateBrandPlanTier(brandId: string, planTier: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
if (!["starter", "farm", "enterprise"].includes(planTier)) {
|
||||
return { success: false, error: "Invalid plan tier" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return { success: false, error: "Failed to fetch plan info" };
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" };
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
// get_brand_features returns JSONB — a single object, not an array
|
||||
if (!res.ok) return {};
|
||||
const data = await res.json();
|
||||
if (typeof data !== "object" || data === null) return {};
|
||||
return data as Record<string, boolean>;
|
||||
}
|
||||
|
||||
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type UploadLogoResult =
|
||||
| { success: true; logoUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function uploadBrandLogo(
|
||||
brandId: string,
|
||||
file: File,
|
||||
isDark: boolean = false
|
||||
): Promise<UploadLogoResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." };
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return { success: false, error: "File too large. Max 5MB." };
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
||||
const storagePath = `brand-logos/${brandId}/${path}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
|
||||
// Save URL to brand_settings via RPC
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!saveRes.ok) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogo(
|
||||
brandId: string,
|
||||
file: File
|
||||
): Promise<UploadLogoResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." };
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return { success: false, error: "File too large. Max 5MB." };
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_olathe_sweet_logo_url: publicUrl,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!saveRes.ok) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogoDark(
|
||||
brandId: string,
|
||||
file: File
|
||||
): Promise<UploadLogoResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." };
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return { success: false, error: "File too large. Max 5MB." };
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_olathe_sweet_logo_url_dark: publicUrl,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!saveRes.ok) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
}
|
||||
|
||||
export type BrandSettings = {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
legal_business_name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website_url: string | null;
|
||||
street_address: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postal_code: string | null;
|
||||
country: string | null;
|
||||
logo_url: string | null;
|
||||
logo_url_dark: string | null;
|
||||
olathe_sweet_logo_url: string | null;
|
||||
olathe_sweet_logo_url_dark: string | null;
|
||||
default_email_signature: string | null;
|
||||
invoice_footer_notes: string | null;
|
||||
// Storefront customization fields
|
||||
hero_tagline: string | null;
|
||||
about_headline: string | null;
|
||||
about_subheadline: string | null;
|
||||
custom_footer_text: string | null;
|
||||
show_wholesale_link: boolean | null;
|
||||
show_zip_search: boolean | null;
|
||||
show_schedule_pdf: boolean | null;
|
||||
show_text_alerts: boolean | null;
|
||||
schedule_pdf_notes: string | null;
|
||||
hero_image_url: string | null;
|
||||
// Color customization
|
||||
brand_primary_color: string | null;
|
||||
brand_secondary_color: string | null;
|
||||
brand_bg_color: string | null;
|
||||
brand_text_color: string | null;
|
||||
// Tax settings
|
||||
collect_sales_tax: boolean | null;
|
||||
nexus_states: string[] | null;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type GetBrandSettingsResult =
|
||||
| { success: true; settings: BrandSettings | null }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type SaveBrandSettingsResult =
|
||||
| { success: true; settings: BrandSettings }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings" };
|
||||
const data = await response.json();
|
||||
return { success: true, settings: data };
|
||||
}
|
||||
|
||||
// Public version for storefront pages — uses slug, no auth required
|
||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
settings: data,
|
||||
wholesaleEnabled: data?.wholesale_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveBrandSettings(params: {
|
||||
brandId: string;
|
||||
legalBusinessName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
websiteUrl?: string;
|
||||
streetAddress?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
logoUrl?: string;
|
||||
logoUrlDark?: string;
|
||||
olatheSweetLogoUrl?: string;
|
||||
olatheSweetLogoUrlDark?: string;
|
||||
defaultEmailSignature?: string;
|
||||
invoiceFooterNotes?: string;
|
||||
// Storefront customization fields
|
||||
heroTagline?: string;
|
||||
aboutHeadline?: string;
|
||||
aboutSubheadline?: string;
|
||||
customFooterText?: string;
|
||||
showWholesaleLink?: boolean;
|
||||
showZipSearch?: boolean;
|
||||
showSchedulePdf?: boolean;
|
||||
showTextAlerts?: boolean;
|
||||
schedulePdfNotes?: string;
|
||||
heroImageUrl?: string;
|
||||
brandPrimaryColor?: string;
|
||||
brandSecondaryColor?: string;
|
||||
brandBgColor?: string;
|
||||
brandTextColor?: string;
|
||||
collectSalesTax?: boolean;
|
||||
nexusStates?: string[];
|
||||
}): Promise<SaveBrandSettingsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_legal_business_name: params.legalBusinessName ?? null,
|
||||
p_phone: params.phone ?? null,
|
||||
p_email: params.email ?? null,
|
||||
p_website_url: params.websiteUrl ?? null,
|
||||
p_street_address: params.streetAddress ?? null,
|
||||
p_city: params.city ?? null,
|
||||
p_state: params.state ?? null,
|
||||
p_postal_code: params.postalCode ?? null,
|
||||
p_country: params.country ?? null,
|
||||
p_logo_url: params.logoUrl ?? null,
|
||||
p_logo_url_dark: params.logoUrlDark ?? null,
|
||||
p_olathe_sweet_logo_url: params.olatheSweetLogoUrl ?? null,
|
||||
p_olathe_sweet_logo_url_dark: params.olatheSweetLogoUrlDark ?? null,
|
||||
p_default_email_signature: params.defaultEmailSignature ?? null,
|
||||
p_invoice_footer_notes: params.invoiceFooterNotes ?? null,
|
||||
p_hero_tagline: params.heroTagline ?? null,
|
||||
p_about_headline: params.aboutHeadline ?? null,
|
||||
p_about_subheadline: params.aboutSubheadline ?? null,
|
||||
p_custom_footer_text: params.customFooterText ?? null,
|
||||
p_show_wholesale_link: params.showWholesaleLink ?? null,
|
||||
p_show_zip_search: params.showZipSearch ?? null,
|
||||
p_show_schedule_pdf: params.showSchedulePdf ?? null,
|
||||
p_show_text_alerts: params.showTextAlerts ?? null,
|
||||
p_schedule_pdf_notes: params.schedulePdfNotes ?? null,
|
||||
p_hero_image_url: params.heroImageUrl ?? null,
|
||||
p_brand_primary_color: params.brandPrimaryColor ?? null,
|
||||
p_brand_secondary_color: params.brandSecondaryColor ?? null,
|
||||
p_brand_bg_color: params.brandBgColor ?? null,
|
||||
p_brand_text_color: params.brandTextColor ?? null,
|
||||
p_collect_sales_tax: params.collectSalesTax ?? null,
|
||||
p_nexus_states: params.nexusStates ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { success: true, settings: data };
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use server";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type CartItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
quantity: number;
|
||||
fulfillment: "pickup" | "ship";
|
||||
brand_id: string;
|
||||
brand_slug: string;
|
||||
};
|
||||
|
||||
type CheckoutItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
fulfillment: "pickup" | "ship";
|
||||
is_taxable?: boolean;
|
||||
};
|
||||
|
||||
type CreatedOrder = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string;
|
||||
subtotal: number;
|
||||
status: string;
|
||||
stop_id: string | null;
|
||||
brand_id: string;
|
||||
stop_city: string;
|
||||
stop_state: string;
|
||||
stop_date: string;
|
||||
stop_time: string | null;
|
||||
stop_location: string;
|
||||
items: {
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fulfillment: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type CheckoutResult =
|
||||
| { success: true; order: CreatedOrder }
|
||||
| { success: false; error: string };
|
||||
|
||||
type ShippingAddress = {
|
||||
state: string;
|
||||
postal_code: string;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
export async function createOrder(
|
||||
idempotencyKey: string,
|
||||
customerName: string,
|
||||
customerEmail: string,
|
||||
customerPhone: string,
|
||||
stopId: string | null,
|
||||
items: CheckoutItem[],
|
||||
brandId?: string,
|
||||
shippingAddress?: ShippingAddress
|
||||
): Promise<CheckoutResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// ── Calculate tax if brand collects tax ─────────────────────────────────
|
||||
let taxAmount = 0;
|
||||
let taxRate = 0;
|
||||
let taxLocation = "";
|
||||
|
||||
if (brandId && shippingAddress) {
|
||||
try {
|
||||
const { calculateOrderTax } = await import("@/actions/tax");
|
||||
const taxResult = await calculateOrderTax({
|
||||
brandId,
|
||||
subtotal: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
|
||||
items: items.map((i) => ({ id: i.id, quantity: i.quantity, price: i.price, is_taxable: i.is_taxable })),
|
||||
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
|
||||
shippingAddress,
|
||||
});
|
||||
taxAmount = taxResult.taxAmount;
|
||||
taxRate = taxResult.taxRate;
|
||||
taxLocation = taxResult.taxLocation;
|
||||
} catch {
|
||||
// Tax calculation failure should not block checkout
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_customer_name: customerName,
|
||||
p_customer_email: customerEmail,
|
||||
p_customer_phone: customerPhone,
|
||||
p_stop_id: stopId,
|
||||
p_items: items,
|
||||
p_tax_amount: taxAmount,
|
||||
p_tax_rate: taxRate,
|
||||
p_tax_location: taxLocation || null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: "Unknown error" }));
|
||||
return { success: false, error: err.message ?? "Failed to create order" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// RPC returns a JSONB object with order + items
|
||||
if (!data || !data.id) {
|
||||
return { success: false, error: "Order created but data not returned" };
|
||||
}
|
||||
|
||||
// Send order receipt email
|
||||
try {
|
||||
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
|
||||
await sendOrderReceiptEmail({
|
||||
customerName,
|
||||
customerEmail,
|
||||
orderId: data.id,
|
||||
items: data.items ?? [],
|
||||
subtotal: data.subtotal ?? 0,
|
||||
taxAmount: data.tax_amount ?? 0,
|
||||
total: (data.subtotal ?? 0) + (data.tax_amount ?? 0),
|
||||
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
|
||||
stopCity: data.stop_city ?? undefined,
|
||||
stopState: data.stop_state ?? undefined,
|
||||
stopDate: data.stop_date ?? undefined,
|
||||
stopTime: data.stop_time ?? undefined,
|
||||
stopLocation: data.stop_location ?? undefined,
|
||||
brandName: "Tuxedo Corn",
|
||||
});
|
||||
} catch (e) {
|
||||
// Email failure should not fail the order
|
||||
}
|
||||
|
||||
return { success: true, order: data as CreatedOrder };
|
||||
}
|
||||
|
||||
// ── Cart Persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getServerCart(userId: string): Promise<CartItem[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeLocalCart(
|
||||
localCart: CartItem[],
|
||||
userId: string
|
||||
): Promise<{ merged: CartItem[] }> {
|
||||
if (!localCart || localCart.length === 0) return { merged: [] };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Fetch server cart
|
||||
let serverCart: CartItem[] = [];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId }),
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
serverCart = Array.isArray(data) ? data : [];
|
||||
}
|
||||
} catch {
|
||||
// proceed with local cart only
|
||||
}
|
||||
|
||||
// Merge: server items take precedence for fulfillment conflicts;
|
||||
// quantities are summed for same product IDs.
|
||||
const mergedMap = new Map<string, CartItem>();
|
||||
|
||||
// Add server items first
|
||||
for (const item of serverCart) {
|
||||
mergedMap.set(item.id, { ...item });
|
||||
}
|
||||
|
||||
// Overlay local items — sum quantities, prefer server fulfillment
|
||||
for (const local of localCart) {
|
||||
const existing = mergedMap.get(local.id);
|
||||
if (existing) {
|
||||
mergedMap.set(local.id, {
|
||||
...existing,
|
||||
quantity: existing.quantity + local.quantity,
|
||||
// Server fulfillment wins if set
|
||||
fulfillment: existing.fulfillment ?? local.fulfillment,
|
||||
});
|
||||
} else {
|
||||
mergedMap.set(local.id, local);
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(mergedMap.values());
|
||||
|
||||
// Persist merged cart to server
|
||||
try {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId, p_items: merged }),
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// best-effort — localStorage is still source of truth for now
|
||||
}
|
||||
|
||||
return { merged };
|
||||
}
|
||||
|
||||
export async function clearServerCart(userId: string): Promise<void> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/clear_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId }),
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
|
||||
|
||||
export type AudienceRules = {
|
||||
target?: "stop" | "zip_code" | "customer_history" | "product" | "customer_ids" | "all_customers";
|
||||
stop_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
zip_codes?: string[];
|
||||
city?: string;
|
||||
order_history?: "all" | "first_order" | "repeat";
|
||||
days_back?: number;
|
||||
product_id?: string;
|
||||
customer_ids?: string[];
|
||||
};
|
||||
|
||||
export type Campaign = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string | null;
|
||||
body_text: string | null;
|
||||
body_html: string | null;
|
||||
template_id: string | null;
|
||||
campaign_type: CampaignType;
|
||||
status: CampaignStatus;
|
||||
audience_rules: AudienceRules;
|
||||
scheduled_at: string | null;
|
||||
sent_at: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertCampaignResult = {
|
||||
success: true;
|
||||
campaign: Campaign;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type ListCampaignsResult = {
|
||||
success: true;
|
||||
campaigns: Campaign[];
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" };
|
||||
const data = await response.json();
|
||||
return { success: true, campaigns: data?.campaigns ?? [] };
|
||||
}
|
||||
|
||||
export async function upsertCampaign(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject?: string;
|
||||
body_text?: string;
|
||||
body_html?: string;
|
||||
template_id?: string;
|
||||
campaign_type: CampaignType;
|
||||
status?: CampaignStatus;
|
||||
audience_rules?: AudienceRules;
|
||||
scheduled_at?: string;
|
||||
}): Promise<UpsertCampaignResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||
return { success: false, error: "Not authorized to operate on this brand's campaigns" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: params.id ?? null,
|
||||
p_brand_id: params.brand_id,
|
||||
p_name: params.name,
|
||||
p_subject: params.subject ?? null,
|
||||
p_body_text: params.body_text ?? null,
|
||||
p_body_html: params.body_html ?? null,
|
||||
p_template_id: params.template_id ?? null,
|
||||
p_campaign_type: params.campaign_type,
|
||||
p_status: params.status ?? "draft",
|
||||
p_audience_rules: params.audience_rules ?? {},
|
||||
p_scheduled_at: params.scheduled_at ?? null,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.id) {
|
||||
return { success: false, error: data?.message ?? "Failed to save campaign" };
|
||||
}
|
||||
|
||||
return { success: true, campaign: data };
|
||||
}
|
||||
|
||||
export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only delete their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && brandId && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized to delete this brand's campaigns" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete campaign" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const campaign = data?.campaign ?? null;
|
||||
|
||||
// Client-side brand validation
|
||||
if (campaign && adminUser.role === "brand_admin" && campaign.brand_id !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return campaign;
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { parseCSVWithLimits } from "@/lib/csv-parser";
|
||||
import { buildImportPreview } from "@/lib/column-detector";
|
||||
|
||||
export type ContactSource = "order" | "import" | "manual" | "admin";
|
||||
|
||||
export type Contact = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
full_name: string | null;
|
||||
source: ContactSource;
|
||||
external_id: string | null;
|
||||
customer_id: string | null;
|
||||
email_opt_in: boolean;
|
||||
sms_opt_in: boolean;
|
||||
email_opt_in_at: string | null;
|
||||
sms_opt_in_at: string | null;
|
||||
unsubscribed_at: string | null;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ContactImportEntry = {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
full_name?: string;
|
||||
tags?: string[];
|
||||
email_opt_in?: boolean;
|
||||
sms_opt_in?: boolean;
|
||||
external_id?: string;
|
||||
/** Ignored columns from the CSV, stored in metadata on import */
|
||||
_metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: { row: ContactImportEntry; error: string }[];
|
||||
};
|
||||
|
||||
// ── Import preview types (column detection) ────────────────────────────────
|
||||
|
||||
export type ImportField =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "first_name"
|
||||
| "last_name"
|
||||
| "full_name"
|
||||
| "tags"
|
||||
| "email_opt_in"
|
||||
| "sms_opt_in"
|
||||
| "external_id"
|
||||
| null;
|
||||
|
||||
export type ColumnMapping = {
|
||||
csvColumn: string;
|
||||
field: ImportField;
|
||||
sampleValues: string[];
|
||||
};
|
||||
|
||||
export type ImportPreviewResult = {
|
||||
mappings: ColumnMapping[];
|
||||
totalRows: number;
|
||||
validRows: number;
|
||||
skippedRows: number;
|
||||
duplicateRows: number;
|
||||
skippedReasons: { rowIndex: number; reason: string }[];
|
||||
sampleRows: ContactImportEntry[];
|
||||
ignoredColumns: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type GetContactsResult = {
|
||||
success: true;
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getContacts(params: {
|
||||
brandId: string;
|
||||
search?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<GetContactsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_search: params.search ?? null,
|
||||
p_source: params.source ?? null,
|
||||
p_limit: params.limit ?? 100,
|
||||
p_offset: params.offset ?? 0,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch contacts" };
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contacts: data?.contacts ?? [],
|
||||
total: data?.total ?? 0,
|
||||
limit: data?.limit ?? 100,
|
||||
offset: data?.offset ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type UpsertContactResult = {
|
||||
success: true;
|
||||
contact: Contact;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function upsertContact(contact: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
full_name?: string;
|
||||
source: ContactSource;
|
||||
external_id?: string;
|
||||
customer_id?: string;
|
||||
email_opt_in?: boolean;
|
||||
sms_opt_in?: boolean;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<UpsertContactResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== contact.brand_id
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_contact`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: contact.id ?? null,
|
||||
p_brand_id: contact.brand_id,
|
||||
p_email: contact.email ?? null,
|
||||
p_phone: contact.phone ?? null,
|
||||
p_first_name: contact.first_name ?? null,
|
||||
p_last_name: contact.last_name ?? null,
|
||||
p_full_name: contact.full_name ?? null,
|
||||
p_source: contact.source,
|
||||
p_external_id: contact.external_id ?? null,
|
||||
p_customer_id: contact.customer_id ?? null,
|
||||
p_email_opt_in: contact.email_opt_in ?? null,
|
||||
p_sms_opt_in: contact.sms_opt_in ?? null,
|
||||
p_tags: contact.tags ?? null,
|
||||
p_metadata: contact.metadata ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to upsert contact" };
|
||||
const data = await response.json();
|
||||
|
||||
if (!data) return { success: false, error: "No data returned" };
|
||||
return { success: true, contact: data as Contact };
|
||||
}
|
||||
|
||||
export type ImportContactsResult = {
|
||||
success: true;
|
||||
result: ImportResult;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function importContactsBatch(params: {
|
||||
brandId: string;
|
||||
contacts: ContactImportEntry[];
|
||||
allowOptInOverride?: boolean;
|
||||
}): Promise<ImportContactsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/import_communication_contacts_batch`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_contacts: params.contacts,
|
||||
p_allow_opt_in_override: params.allowOptInOverride ?? false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to import contacts" };
|
||||
const data = await response.json();
|
||||
|
||||
return { success: true, result: data as ImportResult };
|
||||
}
|
||||
|
||||
export type OptOutResult = {
|
||||
success: true;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function optOutContact(params: {
|
||||
email: string;
|
||||
brandId: string;
|
||||
method: "email" | "sms";
|
||||
}): Promise<OptOutResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/opt_out_contact`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_email: params.email,
|
||||
p_brand_id: params.brandId,
|
||||
p_method: params.method,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to opt out contact" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
brandId &&
|
||||
adminUser.brand_id !== brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_communication_contact`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_id: id }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete contact" };
|
||||
const data = await response.json();
|
||||
return { success: data };
|
||||
}
|
||||
|
||||
// ── Export preview ────────────────────────────────────────────────────────────
|
||||
|
||||
export type ExportContactsResult = {
|
||||
success: true;
|
||||
csv: string;
|
||||
filename: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function escapeCSVValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const str = String(value);
|
||||
if (str.includes('"') || str.includes(",") || str.includes("\n") || str.includes("\r")) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export async function exportContacts(params: {
|
||||
brandId: string;
|
||||
brandSlug?: string;
|
||||
search?: string;
|
||||
source?: string;
|
||||
}): Promise<ExportContactsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId =
|
||||
adminUser.role === "brand_admin" ? adminUser.brand_id! : params.brandId;
|
||||
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand context" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const allContacts: Contact[] = [];
|
||||
let offset = 0;
|
||||
const batchSize = 1000;
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_search: params.search ?? null,
|
||||
p_source: params.source ?? null,
|
||||
p_limit: batchSize,
|
||||
p_offset: offset,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch contacts" };
|
||||
const data = await response.json();
|
||||
const batch: Contact[] = data?.contacts ?? [];
|
||||
|
||||
if (batch.length === 0) break;
|
||||
allContacts.push(...batch);
|
||||
if (batch.length < batchSize) break;
|
||||
offset += batchSize;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
"email",
|
||||
"phone",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"full_name",
|
||||
"source",
|
||||
"email_opt_in",
|
||||
"sms_opt_in",
|
||||
"unsubscribed_at",
|
||||
"tags",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"customer_id",
|
||||
"metadata.last_order_id",
|
||||
"metadata.last_order_at",
|
||||
"metadata.stop_id",
|
||||
"metadata.imported_raw",
|
||||
];
|
||||
|
||||
const rows = allContacts.map((c) => [
|
||||
escapeCSVValue(c.email),
|
||||
escapeCSVValue(c.phone),
|
||||
escapeCSVValue(c.first_name),
|
||||
escapeCSVValue(c.last_name),
|
||||
escapeCSVValue(c.full_name),
|
||||
escapeCSVValue(c.source),
|
||||
c.email_opt_in ? "TRUE" : "FALSE",
|
||||
c.sms_opt_in ? "TRUE" : "FALSE",
|
||||
escapeCSVValue(c.unsubscribed_at),
|
||||
escapeCSVValue(c.tags?.join(";")),
|
||||
escapeCSVValue(c.created_at),
|
||||
escapeCSVValue(c.updated_at),
|
||||
escapeCSVValue(c.customer_id),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_id ?? ""),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_at ?? ""),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.stop_id ?? ""),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.imported_raw ?? ""),
|
||||
]);
|
||||
|
||||
const brandSlug = params.brandSlug ?? "contacts";
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const filename = `${brandSlug}-${dateStr}.csv`;
|
||||
|
||||
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\r\n");
|
||||
|
||||
return { success: true, csv, filename };
|
||||
}
|
||||
|
||||
// ── Import preview ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function previewContactImport(
|
||||
csvText: string
|
||||
): Promise<{ success: true; preview: ImportPreviewResult } | { success: false; error: string }> {
|
||||
try {
|
||||
const { csv, totalRows, warnings } = parseCSVWithLimits(csvText);
|
||||
|
||||
if (warnings.length > 0 && totalRows === 0) {
|
||||
return { success: false, error: warnings[0] };
|
||||
}
|
||||
|
||||
const preview = buildImportPreview(csv.headers, csv.rows);
|
||||
preview.warnings = warnings;
|
||||
|
||||
return { success: true, preview };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Parse error" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type Segment = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rules: AudienceRules;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ListSegmentsResult =
|
||||
| { success: true; segments: Segment[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type UpsertSegmentResult =
|
||||
| { success: true; segment: Segment }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function getCommunicationSegments(
|
||||
brandId: string
|
||||
): Promise<ListSegmentsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
|
||||
const data = await response.json();
|
||||
return { success: true, segments: data?.segments ?? [] };
|
||||
}
|
||||
|
||||
export async function upsertSegment(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
rules: AudienceRules;
|
||||
}): Promise<UpsertSegmentResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: params.id ?? null,
|
||||
p_brand_id: params.brand_id,
|
||||
p_name: params.name,
|
||||
p_description: params.description ?? null,
|
||||
p_rules: params.rules,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to save segment" };
|
||||
const data = await response.json();
|
||||
return { success: true, segment: data };
|
||||
}
|
||||
|
||||
export async function deleteSegment(
|
||||
segmentId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete segment" };
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type AudiencePreviewResult = {
|
||||
count: number;
|
||||
sample_customers: { id: string; email: string; name: string }[];
|
||||
};
|
||||
|
||||
export async function previewCampaignAudience(
|
||||
brandId: string,
|
||||
audienceRules: AudienceRules
|
||||
): Promise<AudiencePreviewResult | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
// Brand scoping: brand_admin can only preview their own brand
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_audience_rules: audienceRules ?? {},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as AudiencePreviewResult;
|
||||
}
|
||||
|
||||
export type MessageLogEntry = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
campaign_id: string | null;
|
||||
customer_id: string | null;
|
||||
customer_email: string | null;
|
||||
delivery_method: string;
|
||||
subject: string | null;
|
||||
body_preview: string | null;
|
||||
status: string;
|
||||
sent_at: string | null;
|
||||
error_message: string | null;
|
||||
event_type: string | null;
|
||||
event_id: string | null;
|
||||
created_at: string;
|
||||
// Analytics columns (populated by Resend webhook)
|
||||
delivered_at: string | null;
|
||||
opened_at: string | null;
|
||||
clicked_at: string | null;
|
||||
bounced_at: string | null;
|
||||
bounce_reason: string | null;
|
||||
};
|
||||
|
||||
export type GetMessageLogsResult = {
|
||||
success: true;
|
||||
logs: MessageLogEntry[];
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getMessageLogs(params: {
|
||||
brandId: string;
|
||||
campaignId?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}): Promise<GetMessageLogsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only view their own brand's logs
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||
return { success: false, error: "Not authorized to view these logs" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_message_logs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_campaign_id: params.campaignId ?? null,
|
||||
p_status: params.status ?? null,
|
||||
p_limit: params.limit ?? 100,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch logs" };
|
||||
const data = await response.json();
|
||||
return { success: true, logs: data?.logs ?? [] };
|
||||
}
|
||||
|
||||
export type SendCampaignResult = {
|
||||
success: true;
|
||||
messages_logged: number;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Resolve brand from campaign or parameter
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id;
|
||||
|
||||
// Brand scoping: brand_admin can only send their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) {
|
||||
return { success: false, error: "Not authorized to send this brand's campaigns" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/send_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: effectiveBrandId }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to send campaign" };
|
||||
}
|
||||
|
||||
return { success: true, messages_logged: data.messages_logged ?? 0 };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type CommunicationSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
default_sender_email: string | null;
|
||||
default_sender_name: string | null;
|
||||
reply_to_email: string | null;
|
||||
email_provider: string;
|
||||
email_footer_html: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertSettingsResult = {
|
||||
success: true;
|
||||
settings: CommunicationSettings;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data?.settings ?? null;
|
||||
}
|
||||
|
||||
export async function upsertCommunicationSettings(params: {
|
||||
brand_id: string;
|
||||
sender_email?: string;
|
||||
sender_name?: string;
|
||||
reply_to_email?: string;
|
||||
provider?: string;
|
||||
footer_html?: string;
|
||||
}): Promise<UpsertSettingsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's settings
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||
return { success: false, error: "Not authorized to modify this brand's communication settings" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brand_id,
|
||||
p_sender_email: params.sender_email ?? null,
|
||||
p_sender_name: params.sender_name ?? null,
|
||||
p_reply_to_email: params.reply_to_email ?? null,
|
||||
p_provider: params.provider ?? "resend",
|
||||
p_footer_html: params.footer_html ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.id) {
|
||||
return { success: false, error: data?.message ?? "Failed to save settings" };
|
||||
}
|
||||
|
||||
return { success: true, settings: data };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type StopBlastResult =
|
||||
| { success: true; campaign_id: string; messages_logged: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function sendStopBlast(params: {
|
||||
stopId: string;
|
||||
brandId: string;
|
||||
channel: "sms" | "email" | "both";
|
||||
subject?: string;
|
||||
body: string;
|
||||
audience: "all" | "pending" | "picked_up";
|
||||
}): Promise<StopBlastResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/send_stop_blast`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_stop_id: params.stopId,
|
||||
p_brand_id: params.brandId,
|
||||
p_channel: params.channel,
|
||||
p_subject: params.subject ?? null,
|
||||
p_body: params.body,
|
||||
p_audience: params.audience,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
return { success: false, error: err?.message ?? "Failed to send blast" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
campaign_id: data.campaign_id,
|
||||
messages_logged: data.messages_logged,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
|
||||
export type Template = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
body_html: string | null;
|
||||
template_type: TemplateType;
|
||||
campaign_type: CampaignType | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertTemplateResult = {
|
||||
success: true;
|
||||
template: Template;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Brand scoping: brand_admin can only see their own brand's templates
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
||||
return { success: true, templates: [] };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch templates" };
|
||||
const data = await response.json();
|
||||
return { success: true, templates: data?.templates ?? [] };
|
||||
}
|
||||
|
||||
export async function upsertTemplate(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
body_html?: string;
|
||||
template_type: TemplateType;
|
||||
campaign_type?: CampaignType;
|
||||
}): Promise<UpsertTemplateResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_template`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: params.id ?? null,
|
||||
p_brand_id: params.brand_id,
|
||||
p_name: params.name,
|
||||
p_subject: params.subject,
|
||||
p_body_text: params.body_text,
|
||||
p_body_html: params.body_html ?? null,
|
||||
p_template_type: params.template_type,
|
||||
p_campaign_type: params.campaign_type ?? null,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.id) {
|
||||
return { success: false, error: data?.message ?? "Failed to save template" };
|
||||
}
|
||||
|
||||
return { success: true, template: data };
|
||||
}
|
||||
|
||||
export async function getTemplateById(templateId: string): Promise<Template | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const templates: Template[] = data?.templates ?? [];
|
||||
const template = templates.find((t) => t.id === templateId) ?? null;
|
||||
|
||||
// Brand scoping: brand_admin can only see their own brand's templates
|
||||
if (template && adminUser.role === "brand_admin" && template.brand_id !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AbandonedCart = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
customer_id: string | null;
|
||||
contact_email: string;
|
||||
contact_name: string | null;
|
||||
cart_snapshot: {
|
||||
items: { name: string; quantity: number; unit_price: number }[];
|
||||
subtotal: number;
|
||||
item_count: number;
|
||||
};
|
||||
brand_name: string | null;
|
||||
locale: string;
|
||||
sequence_step: number;
|
||||
last_email_sent_at: string | null;
|
||||
next_email_at: string | null;
|
||||
status: string;
|
||||
recovered_order_id: string | null;
|
||||
recovered_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type GetAbandonedCartsResult = {
|
||||
success: true;
|
||||
carts: AbandonedCart[];
|
||||
stats: { total: number; recovered: number; active: number; expired: number };
|
||||
} | { success: false; error: string };
|
||||
|
||||
// ── Sequence email intervals ───────────────────────────────────────────────────
|
||||
|
||||
const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const;
|
||||
const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = {
|
||||
en: {
|
||||
1: {
|
||||
subject: "Your cart is waiting — complete your order",
|
||||
heading: "Don't forget your order",
|
||||
body: "You left items in your cart. Complete your order before the pickup date fills up.",
|
||||
},
|
||||
2: {
|
||||
subject: "Still thinking it over? Your cart is still here",
|
||||
heading: "A little reminder",
|
||||
body: "Your wholesale order is still waiting. Lock in your pickup date before it books up.",
|
||||
},
|
||||
3: {
|
||||
subject: "Last chance — your cart expires soon",
|
||||
heading: "One more day to order",
|
||||
body: "This is your final reminder. After this, your cart will no longer be available.",
|
||||
},
|
||||
},
|
||||
es: {
|
||||
1: {
|
||||
subject: "Tu carrito te espera — completa tu pedido",
|
||||
heading: "No olvides tu pedido",
|
||||
body: "Dejaste artículos en tu carrito. Completa tu pedido antes de que se llene la fecha de recogida.",
|
||||
},
|
||||
2: {
|
||||
subject: "¿Aún lo estás pensando? Tu carrito sigue aquí",
|
||||
heading: "Un pequeño recordatorio",
|
||||
body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.",
|
||||
},
|
||||
3: {
|
||||
subject: "Última oportunidad — tu carrito expira pronto",
|
||||
heading: "Un día más para ordenar",
|
||||
body: "Este es tu último recordatorio. Después de esto, tu carrito ya no estará disponible.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Get all carts (admin view) ─────────────────────────────────────────────────
|
||||
|
||||
export async function getAbandonedCarts(brandId: string): Promise<GetAbandonedCartsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_abandoned_carts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to fetch abandoned carts" };
|
||||
const data = await res.json();
|
||||
// Supabase TABLE(func) returns [{carts: ...}] — extract from first row
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
const carts: AbandonedCart[] = row?.carts ?? [];
|
||||
return {
|
||||
success: true,
|
||||
carts,
|
||||
stats: {
|
||||
total: carts.length,
|
||||
recovered: carts.filter(c => c.status === "recovered").length,
|
||||
active: carts.filter(c => c.status === "active").length,
|
||||
expired: carts.filter(c => c.status === "expired").length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Manually close an abandoned cart ──────────────────────────────────────────
|
||||
|
||||
export async function manuallyCloseAbandonedCart(
|
||||
cartId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: cartId,
|
||||
p_status: "manually_closed",
|
||||
p_manually_closed_by: adminUser.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to close cart" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Build email HTML ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildCartRecoveryEmail(params: {
|
||||
brandName: string;
|
||||
contactName: string | null;
|
||||
locale: string;
|
||||
step: number;
|
||||
cartSnapshot: AbandonedCart["cart_snapshot"];
|
||||
adminUrl: string;
|
||||
}): { subject: string; html: string; text: string } {
|
||||
const { brandName, contactName, locale, step, cartSnapshot, adminUrl } = params;
|
||||
const t = LOCALE_CART_SUBJECT[locale]?.[step] ?? LOCALE_CART_SUBJECT.en[step];
|
||||
const greeting = locale === "es"
|
||||
? (contactName ? `Hola ${contactName}` : `Hola`)
|
||||
: (contactName ? `Hi ${contactName}` : `Hi there`);
|
||||
const ctaText = locale === "es" ? "Completar mi pedido" : "Complete my order";
|
||||
const footerText = locale === "es"
|
||||
? "Este email fue enviado porque dejaste artículos en tu carrito."
|
||||
: "You received this email because you left items in your cart.";
|
||||
|
||||
const itemsList = cartSnapshot.items
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
item =>
|
||||
`<tr><td style="padding:8px 0;border-bottom:1px solid #27272a;color:#a8a29e">${item.name}</td><td style="padding:8px 0;border-bottom:1px solid #27272a;color:#fafafa;text-align:right">${item.quantity} × $${Number(item.unit_price).toFixed(2)}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const subject = t.subject;
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#0f0f0f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fafafa">
|
||||
<div style="max-width:560px;margin:0 auto;padding:32px 16px">
|
||||
<div style="background:#1c1917;border-radius:16px;padding:28px 32px;margin-bottom:24px">
|
||||
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#a8a29e">${locale === "es" ? "Carrito abandonado" : "Abandoned Cart"}</p>
|
||||
<h1 style="margin:0;font-size:22px;font-weight:800;color:#ffffff">${t.heading}</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,.6)">${brandName}</p>
|
||||
</div>
|
||||
<div style="background:#18181b;border-radius:16px;padding:28px 32px;border:1px solid #27272a;margin-bottom:24px">
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#fafafa">${greeting},</p>
|
||||
<p style="margin:0 0 20px;font-size:14px;color:#a8a29e;line-height:1.6">${t.body}</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;margin-bottom:20px">
|
||||
${itemsList}
|
||||
<tr><td style="padding:8px 0;color:#a8a29e">Total</td><td style="padding:8px 0;color:#fafafa;font-weight:700;text-align:right">$${Number(cartSnapshot.subtotal).toFixed(2)}</td></tr>
|
||||
</table>
|
||||
<a href="${adminUrl}" style="display:inline-block;width:100%;text-align:center;background:#16a34a;color:#fff;text-decoration:none;font-weight:700;font-size:14px;padding:14px 28px;border-radius:8px">${ctaText}</a>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:24px">
|
||||
<p style="margin:0;font-size:12px;color:#52525b">${footerText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = `[${brandName} - ${t.heading}]\n\n${greeting},\n\n${t.body}\n\nItems in cart (${cartSnapshot.item_count} items):\n${cartSnapshot.items.map(i => `- ${i.name}: ${i.quantity} × $${Number(i.unit_price).toFixed(2)}`).join("\n")}\nTotal: $${Number(cartSnapshot.subtotal).toFixed(2)}\n\nComplete your order: ${adminUrl}\n\n${footerText}`;
|
||||
|
||||
return { subject, html, text };
|
||||
}
|
||||
|
||||
// ── Internal: send one recovery email ─────────────────────────────────────────
|
||||
|
||||
export async function sendAbandonedCartEmail(
|
||||
cart: AbandonedCart,
|
||||
step: number,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUrl = `https://route-commerce-platform.vercel.app/admin/communications/abandoned-carts`;
|
||||
const { subject, html, text } = buildCartRecoveryEmail({
|
||||
brandName: cart.brand_name ?? "Our Farm",
|
||||
contactName: cart.contact_name,
|
||||
locale: cart.locale ?? "en",
|
||||
step,
|
||||
cartSnapshot: cart.cart_snapshot,
|
||||
adminUrl,
|
||||
});
|
||||
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
|
||||
if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" };
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: process.env.FROM_EMAIL ?? "Route Commerce <no-reply@routecommerce.com>",
|
||||
to: [cart.contact_email],
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: err };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Update cart record
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: cart.id,
|
||||
p_sequence_step: step,
|
||||
p_last_email_sent_at: new Date().toISOString(),
|
||||
p_next_email_at: step < 3 ? new Date(Date.now() + EMAIL_INTERVALS_HOURS[step] * 3600000).toISOString() : null,
|
||||
p_status: step >= 3 ? "expired" : "active",
|
||||
p_expired_at: step >= 3 ? new Date().toISOString() : null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manually resend a specific email step ─────────────────────────────────────
|
||||
|
||||
export async function resendAbandonedCartEmail(
|
||||
cartId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/manual_resend_abandoned_cart_email`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_cart_id: cartId, p_step: 1 }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to resend email" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Mark cart as recovered when order is placed ────────────────────────────────
|
||||
|
||||
export async function markCartRecovered(cartId: string, orderId: string): Promise<void> {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: cartId,
|
||||
p_status: "recovered",
|
||||
p_recovered_order_id: orderId,
|
||||
p_recovered_at: new Date().toISOString(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WelcomeSequenceEntry = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
contact_id: string | null;
|
||||
contact_email: string;
|
||||
contact_name: string | null;
|
||||
brand_name: string | null;
|
||||
locale: string;
|
||||
sequence_step: number;
|
||||
last_email_sent_at: string | null;
|
||||
next_email_at: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type GetWelcomeSequenceResult = {
|
||||
success: true;
|
||||
entries: WelcomeSequenceEntry[];
|
||||
stats: { total: number; completed: number; active: number; unsubscribed: number };
|
||||
} | { success: false; error: string };
|
||||
|
||||
// ── Welcome email content (EN + ES) ──────────────────────────────────────────
|
||||
|
||||
type WelcomeEmailContent = {
|
||||
subject: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
cta_text: string;
|
||||
cta_url: string;
|
||||
};
|
||||
|
||||
const WELCOME_EMAILS: Record<string, Record<number, WelcomeEmailContent>> = {
|
||||
en: {
|
||||
1: {
|
||||
subject: "Welcome to {brand} — here's what to expect",
|
||||
heading: "You're in!",
|
||||
body: "Thanks for subscribing to {brand}'s wholesale updates. Here's what you can expect:\n\n• New product announcements before anyone else\n• Exclusive wholesale pricing\n• Seasonal availability alerts\n• Quick reorder from your saved preferences",
|
||||
cta_text: "Explore wholesale catalog",
|
||||
cta_url: "/wholesale",
|
||||
},
|
||||
2: {
|
||||
subject: "How wholesale ordering works with {brand}",
|
||||
heading: "Ordering is simple",
|
||||
body: "Here's how our wholesale process works:\n\n1. Browse the catalog and add items to your cart\n2. Checkout and pay online, or request an invoice\n3. Choose your pickup date at checkout\n4. We'll have your order ready when you arrive\n\nNo account required to browse — sign up only when you're ready to order.",
|
||||
cta_text: "See current availability",
|
||||
cta_url: "/wholesale/portal",
|
||||
},
|
||||
3: {
|
||||
subject: "Your first order is waiting — {brand} wholesale",
|
||||
heading: "Ready to try us out?",
|
||||
body: "If you've been thinking about placing your first wholesale order with {brand}, now's a great time.\n\nOur current seasonal selection includes produce from our farm and partner growers, sourced for freshness and quality.\n\nQuestions? Reply to this email — we read every message.",
|
||||
cta_text: "Start my first order",
|
||||
cta_url: "/wholesale/register",
|
||||
},
|
||||
4: {
|
||||
subject: "You're all set — wholesale updates from {brand}",
|
||||
heading: "You're all set",
|
||||
body: "You're now fully set up to receive wholesale updates from {brand}.\n\nWe'll send you occasional emails about new products, seasonal availability, and any special offers. No spam — just the useful stuff.\n\nYou can unsubscribe at any time.",
|
||||
cta_text: "Browse the catalog",
|
||||
cta_url: "/wholesale/portal",
|
||||
},
|
||||
},
|
||||
es: {
|
||||
1: {
|
||||
subject: "Bienvenido a {brand} — esto es lo que puedes esperar",
|
||||
heading: "¡Bienvenido!",
|
||||
body: "Gracias por suscribirte a las actualizaciones mayoristas de {brand}. Esto es lo que puedes esperar:\n\n• Anuncios de nuevos productos antes que nadie\n• Precios exclusivos al por mayor\n• Alertas de disponibilidad por temporada\n• Reorden rápido desde tus preferencias guardadas",
|
||||
cta_text: "Explorar catálogo mayorista",
|
||||
cta_url: "/wholesale",
|
||||
},
|
||||
2: {
|
||||
subject: "Cómo funciona el pedido al por mayor con {brand}",
|
||||
heading: "Ordenar es simple",
|
||||
body: "Así funciona nuestro proceso mayorista:\n\n1. Explora el catálogo y agrega artículos a tu carrito\n2. Paga en línea o solicita una factura\n3. Elige tu fecha de recogida al pagar\n4. Tendremos tu pedido listo cuando llegues\n\nNo necesitas cuenta para浏览 — regístrate solo cuando estés listo para ordenar.",
|
||||
cta_text: "Ver disponibilidad actual",
|
||||
cta_url: "/wholesale/portal",
|
||||
},
|
||||
3: {
|
||||
subject: "Tu primer pedido está esperando — {brand} mayorista",
|
||||
heading: "¿Listo para probarnos?",
|
||||
body: "Si has estado pensando en hacer tu primer pedido al por mayor con {brand}, ahora es un excelente momento.\n\nNuestra selección actual de temporada incluye productos de nuestra granja y productores asociados, seleccionados por su frescura y calidad.\n\n¿Preguntas? Responde a este correo — leemos cada mensaje.",
|
||||
cta_text: "Comenzar mi primer pedido",
|
||||
cta_url: "/wholesale/register",
|
||||
},
|
||||
4: {
|
||||
subject: "Todo listo — actualizaciones mayoristas de {brand}",
|
||||
heading: "Todo está listo",
|
||||
body: "Ahora estás completamente configurado para recibir actualizaciones mayoristas de {brand}.\n\nTe enviaremos correos ocasionales sobre nuevos productos, disponibilidad por temporada y ofertas especiales. Sin spam — solo cosas útiles.\n\nPuedes darte de baja en cualquier momento.",
|
||||
cta_text: "Explorar el catálogo",
|
||||
cta_url: "/wholesale/portal",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Get all welcome sequence entries (admin view) ────────────────────────────
|
||||
|
||||
export async function getWelcomeSequence(brandId: string): Promise<GetWelcomeSequenceResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_welcome_sequence`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to fetch welcome sequence" };
|
||||
const data = await res.json();
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
|
||||
return {
|
||||
success: true,
|
||||
entries,
|
||||
stats: {
|
||||
total: entries.length,
|
||||
completed: entries.filter(e => e.status === "completed").length,
|
||||
active: entries.filter(e => e.status === "active").length,
|
||||
unsubscribed: entries.filter(e => e.status === "unsubscribed").length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Build welcome email HTML ───────────────────────────────────────────────────
|
||||
|
||||
function buildWelcomeEmail(params: {
|
||||
brandName: string;
|
||||
contactName: string | null;
|
||||
locale: string;
|
||||
step: number;
|
||||
ctaUrl: string;
|
||||
}): { subject: string; html: string; text: string } {
|
||||
const { brandName, contactName, locale, step, ctaUrl } = params;
|
||||
const t = WELCOME_EMAILS[locale]?.[step] ?? WELCOME_EMAILS.en[step];
|
||||
const greeting = locale === "es"
|
||||
? (contactName ? `Hola ${contactName}` : `Hola`)
|
||||
: (contactName ? `Hi ${contactName}` : `Hi there`);
|
||||
const fullCtaUrl = ctaUrl.startsWith("http") ? ctaUrl : `https://route-commerce-platform.vercel.app${ctaUrl}`;
|
||||
const footerText = locale === "es"
|
||||
? "Te suscribiste a actualizaciones mayoristas de {brand}. Cancela la suscripción en cualquier momento."
|
||||
: "You subscribed to wholesale updates from {brand}. Unsubscribe at any time.";
|
||||
|
||||
const subject = t.subject.replace("{brand}", brandName);
|
||||
const heading = t.heading;
|
||||
const body = t.body.replace(/{brand}/g, brandName);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#0f0f0f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fafafa">
|
||||
<div style="max-width:560px;margin:0 auto;padding:32px 16px">
|
||||
<div style="background:#1c1917;border-radius:16px;padding:28px 32px;margin-bottom:24px">
|
||||
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#a8a29e">${locale === "es" ? "Bienvenido" : "Welcome"}</p>
|
||||
<h1 style="margin:0;font-size:22px;font-weight:800;color:#ffffff">${heading}</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,.6)">${brandName}</p>
|
||||
</div>
|
||||
<div style="background:#18181b;border-radius:16px;padding:28px 32px;border:1px solid #27272a;margin-bottom:24px">
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#fafafa">${greeting},</p>
|
||||
<p style="margin:0 0 20px;font-size:14px;color:#a8a29e;line-height:1.7;white-space:pre-wrap">${body}</p>
|
||||
<a href="${fullCtaUrl}" style="display:inline-block;text-align:center;background:#16a34a;color:#fff;text-decoration:none;font-weight:700;font-size:14px;padding:14px 28px;border-radius:8px;margin-top:8px">${t.cta_text}</a>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:24px">
|
||||
<p style="margin:0;font-size:12px;color:#52525b">${footerText.replace("{brand}", brandName)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = `[${brandName} - ${heading}]\n\n${greeting},\n\n${body}\n\n${t.cta_text}: ${fullCtaUrl}\n\n${footerText.replace("{brand}", brandName)}`;
|
||||
|
||||
return { subject, html, text };
|
||||
}
|
||||
|
||||
// ── Send one welcome email ─────────────────────────────────────────────────────
|
||||
|
||||
export async function sendWelcomeEmail(
|
||||
entry: WelcomeSequenceEntry,
|
||||
step: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const { brand_name, contact_name, locale, contact_email } = entry;
|
||||
const t = WELCOME_EMAILS[locale]?.[step] ?? WELCOME_EMAILS.en[step];
|
||||
const ctaUrl = t.cta_url;
|
||||
|
||||
const { subject, html, text } = buildWelcomeEmail({
|
||||
brandName: brand_name ?? "Our Farm",
|
||||
contactName: contact_name,
|
||||
locale: locale ?? "en",
|
||||
step,
|
||||
ctaUrl,
|
||||
});
|
||||
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
|
||||
if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" };
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: process.env.FROM_EMAIL ?? "Route Commerce <no-reply@routecommerce.com>",
|
||||
to: [contact_email],
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: err };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const isLastEmail = step >= 4;
|
||||
|
||||
// Update sequence entry
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_welcome_sequence`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: entry.id,
|
||||
p_sequence_step: step,
|
||||
p_last_email_sent_at: new Date().toISOString(),
|
||||
p_next_email_at: isLastEmail ? null : new Date(Date.now() + 24 * 3600000).toISOString(),
|
||||
p_status: isLastEmail ? "completed" : "active",
|
||||
p_completed_at: isLastEmail ? new Date().toISOString() : null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manually resend a specific welcome email step ─────────────────────────────
|
||||
|
||||
export async function resendWelcomeEmail(
|
||||
entryId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/manual_resend_welcome_email`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_entry_id: entryId, p_step: 1 }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to resend email" };
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
|
||||
|
||||
export type Campaign = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string | null;
|
||||
body_text: string | null;
|
||||
body_html: string | null;
|
||||
template_id: string | null;
|
||||
campaign_type: CampaignType;
|
||||
status: CampaignStatus;
|
||||
audience_rules: AudienceRules;
|
||||
scheduled_at: string | null;
|
||||
sent_at: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CampaignAnalytics = {
|
||||
campaign_id: string;
|
||||
campaign_name: string;
|
||||
total_sent: number;
|
||||
total_delivered: number;
|
||||
total_opened: number;
|
||||
total_clicked: number;
|
||||
total_bounced: number;
|
||||
delivered_rate: number;
|
||||
open_rate: number;
|
||||
click_rate: number;
|
||||
bounce_rate: number;
|
||||
sent_at: string | null;
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// getHarvestReachCampaigns
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getHarvestReachCampaigns(
|
||||
brandId: string
|
||||
): Promise<{ success: true; campaigns: Campaign[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" };
|
||||
const data = await response.json();
|
||||
return { success: true, campaigns: data?.campaigns ?? [] };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// getCampaignAnalytics
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCampaignAnalytics(
|
||||
brandId: string,
|
||||
campaignId?: string
|
||||
): Promise<CampaignAnalytics[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_campaign_analytics`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_campaign_id: campaignId ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()) as CampaignAnalytics[];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type ProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export async function getProductsForSegmentPicker(
|
||||
brandId: string
|
||||
): Promise<ProductOption[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_products_for_segment_picker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()) as ProductOption[];
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||
|
||||
export type SegmentFilterType =
|
||||
| "all_customers"
|
||||
| "stop"
|
||||
| "upcoming_stop"
|
||||
| "product"
|
||||
| "zip_code"
|
||||
| "customer_history"
|
||||
| "tags";
|
||||
|
||||
export type SegmentFilterParams = {
|
||||
stop_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
zip_codes?: string[];
|
||||
city?: string;
|
||||
order_history?: "all" | "first_order" | "repeat";
|
||||
days_back?: number;
|
||||
product_id?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type SegmentFilter = {
|
||||
type: SegmentFilterType;
|
||||
params: SegmentFilterParams;
|
||||
};
|
||||
|
||||
export type SegmentRuleV2 = {
|
||||
combinator: "AND" | "OR";
|
||||
filters: SegmentFilter[];
|
||||
};
|
||||
|
||||
// AudienceRules is imported from @/actions/communications/campaigns
|
||||
|
||||
export type Segment = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rules: SegmentRuleV2 | AudienceRules;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CustomerSample = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
phone: string | null;
|
||||
};
|
||||
|
||||
export type PreviewResult = {
|
||||
count: number;
|
||||
sample_customers: CustomerSample[];
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// getHarvestReachSegments
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getHarvestReachSegments(
|
||||
brandId: string
|
||||
): Promise<{ success: true; segments: Segment[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
|
||||
const data = await response.json();
|
||||
return { success: true, segments: data?.segments ?? [] };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// upsertHarvestReachSegment
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertHarvestReachSegment(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
rules: SegmentRuleV2;
|
||||
}): Promise<{ success: true; segment: Segment } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: params.id ?? null,
|
||||
p_brand_id: params.brand_id,
|
||||
p_name: params.name,
|
||||
p_description: params.description ?? null,
|
||||
p_rules: params.rules,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to save segment" };
|
||||
const data = await response.json();
|
||||
return { success: true, segment: data };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// deleteHarvestReachSegment
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteHarvestReachSegment(
|
||||
segmentId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete segment" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// previewSegmentWithCustomers
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function previewSegmentWithCustomers(
|
||||
brandId: string,
|
||||
rules: SegmentRuleV2
|
||||
): Promise<PreviewResult | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_audience_rules: rules,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as PreviewResult;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type StopOption = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
zip: string;
|
||||
is_upcoming: boolean;
|
||||
is_past: boolean;
|
||||
};
|
||||
|
||||
export async function getStopsForSegmentPicker(
|
||||
brandId: string,
|
||||
stopId?: string
|
||||
): Promise<StopOption[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_stops_for_segment_picker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_stop_id: stopId ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()) as StopOption[];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type ImportOrdersResult =
|
||||
| { success: true; imported: number; errors: { row: number; error: string }[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function importOrdersBatch(
|
||||
brandId: string,
|
||||
orders: Array<{
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string;
|
||||
stop_id: string;
|
||||
items: Array<{ product_id: string; quantity: number; fulfillment: string }>;
|
||||
}>
|
||||
): Promise<ImportOrdersResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const results = { imported: 0, errors: [] as { row: number; error: string }[] };
|
||||
|
||||
for (const order of orders) {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_customer_name: order.customer_name,
|
||||
p_customer_email: order.customer_email,
|
||||
p_customer_phone: order.customer_phone,
|
||||
p_stop_id: order.stop_id,
|
||||
p_items: order.items.map((it) => ({
|
||||
id: it.product_id,
|
||||
quantity: it.quantity,
|
||||
fulfillment: it.fulfillment,
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
results.errors.push({ row: 0, error: `Failed to import order for ${order.customer_name}` });
|
||||
} else {
|
||||
results.imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, imported: results.imported, errors: results.errors };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type ImportProductsResult =
|
||||
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function importProductsBatch(
|
||||
brandId: string,
|
||||
products: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string;
|
||||
}>
|
||||
): Promise<ImportProductsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_products: products }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Import failed" };
|
||||
const data = await response.json();
|
||||
return { success: true, created: data.created, updated: data.updated, errors: data.errors ?? [] };
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
|
||||
export type AIProviderSettings = {
|
||||
provider: AIProvider;
|
||||
apiKey: string;
|
||||
orgId?: string;
|
||||
model: string;
|
||||
customEndpoint?: string;
|
||||
};
|
||||
|
||||
export type CustomIntegration = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "ai_provider" | "payment_processor" | "other";
|
||||
enabled: boolean;
|
||||
credentials: Record<string, string>;
|
||||
syncOptions: string[];
|
||||
testEndpoint?: string;
|
||||
};
|
||||
|
||||
// ── Get AI provider settings ─────────────────────────────────────────────────────
|
||||
|
||||
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
provider: (data.provider as AIProvider) ?? "openai",
|
||||
apiKey: data.api_key ?? "",
|
||||
orgId: data.org_id,
|
||||
model: data.model ?? "gpt-4o-mini",
|
||||
customEndpoint: data.custom_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Save AI provider settings ───────────────────────────────────────────────────
|
||||
|
||||
export async function setAIProviderSettings(
|
||||
brandId: string,
|
||||
settings: Partial<AIProviderSettings>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const current = await getAIProviderSettings(brandId);
|
||||
const merged = { ...current, ...settings };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const payload = {
|
||||
provider: merged.provider,
|
||||
api_key: merged.apiKey,
|
||||
org_id: merged.orgId ?? null,
|
||||
model: merged.model,
|
||||
custom_endpoint: merged.customEndpoint ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data) {
|
||||
return { success: false, error: data?.message ?? "Failed to save" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Dynamic AI client factory ────────────────────────────────────────────────────
|
||||
|
||||
export async function getAIClient(brandId: string): Promise<{
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
client: unknown;
|
||||
} | { provider: "openai"; model: string; client: null; error: string }> {
|
||||
const settings = await getAIProviderSettings(brandId);
|
||||
|
||||
if (!settings.apiKey) {
|
||||
// Fall back to env var
|
||||
const envKey = process.env.OPENAI_API_KEY;
|
||||
if (!envKey) return { provider: "openai", model: "gpt-4o-mini", client: null, error: "No API key configured" };
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "openai",
|
||||
model: settings.model || "gpt-4o-mini",
|
||||
client: new OpenAI({ apiKey: envKey }),
|
||||
};
|
||||
}
|
||||
|
||||
switch (settings.provider) {
|
||||
case "anthropic": {
|
||||
const { Anthropic } = await import("@anthropic-ai/sdk");
|
||||
return {
|
||||
provider: "anthropic",
|
||||
model: settings.model || "claude-3-5-sonnet-20241002",
|
||||
client: new Anthropic({ apiKey: settings.apiKey }),
|
||||
};
|
||||
}
|
||||
case "google": {
|
||||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||||
return {
|
||||
provider: "google",
|
||||
model: settings.model || "gemini-2.0-flash",
|
||||
client: new GoogleGenerativeAI(settings.apiKey),
|
||||
};
|
||||
}
|
||||
case "xai": {
|
||||
const { OpenAI: XAIOpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "xai",
|
||||
model: settings.model || "grok-2",
|
||||
client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }),
|
||||
};
|
||||
}
|
||||
case "custom": {
|
||||
if (!settings.customEndpoint) {
|
||||
// @ts-expect-error - intentionally returning extra property for error signaling
|
||||
return { provider: "custom", model: settings.model, client: null, error: "Custom endpoint required" };
|
||||
}
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "custom",
|
||||
model: settings.model || "gpt-4o-mini",
|
||||
client: new OpenAI({ apiKey: settings.apiKey, baseURL: settings.customEndpoint }),
|
||||
};
|
||||
}
|
||||
case "openai":
|
||||
default: {
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "openai",
|
||||
model: settings.model || "gpt-4o-mini",
|
||||
client: new OpenAI({ apiKey: settings.apiKey }),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom integrations ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function upsertCustomIntegration(
|
||||
brandId: string,
|
||||
integration: CustomIntegration
|
||||
): Promise<{ success: boolean; integrations?: CustomIntegration[]; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_integration: integration }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" };
|
||||
|
||||
return { success: true, integrations: data };
|
||||
}
|
||||
|
||||
export async function deleteCustomIntegration(
|
||||
brandId: string,
|
||||
integrationId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_integration_id: integrationId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
return { success: false, error: data?.message ?? "Failed to delete" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ResendCredentials = {
|
||||
api_key: string | null;
|
||||
from_email: string | null;
|
||||
from_name: string | null;
|
||||
};
|
||||
|
||||
export type TwilioCredentials = {
|
||||
account_sid: string | null;
|
||||
auth_token: string | null;
|
||||
phone_number: string | null;
|
||||
};
|
||||
|
||||
export type SaveCredentialsResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── Resend Credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { api_key: null, from_email: null, from_name: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
api_key: data?.api_key ?? null,
|
||||
from_email: data?.from_email ?? null,
|
||||
from_name: data?.from_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveResendCredentials(
|
||||
brandId: string,
|
||||
credentials: Partial<{
|
||||
api_key: string | null;
|
||||
from_email: string | null;
|
||||
from_name: string | null;
|
||||
}>
|
||||
): Promise<SaveCredentialsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's settings
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get current credentials to merge
|
||||
const current = await getResendCredentials(brandId);
|
||||
const merged = {
|
||||
api_key: credentials.api_key !== undefined ? credentials.api_key : current.api_key,
|
||||
from_email: credentials.from_email !== undefined ? credentials.from_email : current.from_email,
|
||||
from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_resend_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_credentials: merged,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to save Resend credentials" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Twilio Credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { account_sid: null, auth_token: null, phone_number: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
account_sid: data?.account_sid ?? null,
|
||||
auth_token: data?.auth_token ?? null,
|
||||
phone_number: data?.phone_number ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveTwilioCredentials(
|
||||
brandId: string,
|
||||
credentials: Partial<{
|
||||
account_sid: string | null;
|
||||
auth_token: string | null;
|
||||
phone_number: string | null;
|
||||
}>
|
||||
): Promise<SaveCredentialsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's settings
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get current credentials to merge
|
||||
const current = await getTwilioCredentials(brandId);
|
||||
const merged = {
|
||||
account_sid: credentials.account_sid !== undefined ? credentials.account_sid : current.account_sid,
|
||||
auth_token: credentials.auth_token !== undefined ? credentials.auth_token : current.auth_token,
|
||||
phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_credentials: merged,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to save Twilio credentials" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Test Connection Functions ─────────────────────────────────────────────────
|
||||
|
||||
export async function testResendConnection(apiKey: string): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
if (!apiKey?.trim()) {
|
||||
return { ok: false, message: "API key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.resend.com/domains", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "Successfully connected to Resend" };
|
||||
} else {
|
||||
const error = await response.json().catch(() => ({ message: "Invalid API key" }));
|
||||
return { ok: false, message: error.message ?? "Connection failed" };
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, message: "Network error - please check your connection" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testTwilioConnection(
|
||||
accountSid: string,
|
||||
authToken: string
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
if (!accountSid?.trim() || !authToken?.trim()) {
|
||||
return { ok: false, message: "Account SID and Auth Token are required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
|
||||
const response = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${accountSid}.json`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "Successfully connected to Twilio" };
|
||||
} else {
|
||||
return { ok: false, message: "Invalid Account SID or Auth Token" };
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, message: "Network error - please check your connection" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
|
||||
export type LoginWithPasswordResult =
|
||||
| { success: true; redirect: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function loginWithPassword(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<LoginWithPasswordResult> {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
return { success: false, error: "Server misconfiguration." };
|
||||
}
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookieStore.set(name, value, options);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error || !data.user) {
|
||||
return { success: false, error: error?.message || "Invalid credentials" };
|
||||
}
|
||||
|
||||
// Set the rc_auth_uid cookie that getAdminUser() reads
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
cookieStore.set("rc_auth_uid", data.user.id, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isProd,
|
||||
});
|
||||
|
||||
return { success: true, redirect: true };
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { mockOrders, mockStops } from "@/lib/mock-data";
|
||||
|
||||
type AdminOrder = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
stop_id: string | null;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
pickup_complete: boolean;
|
||||
pickup_completed_at: string | null;
|
||||
pickup_completed_by: string | null;
|
||||
created_at: string;
|
||||
payment_processor: string | null;
|
||||
// For AdminOrdersPanel: stops is { city, state, date }
|
||||
// For DriverPickupPanel: stops is { id, city, state, date, brand_id }
|
||||
// Both share city/state/date so we use the broader shape
|
||||
stops: {
|
||||
id?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
brand_id?: string;
|
||||
} | null;
|
||||
order_items?: Array<{
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fulfillment?: string;
|
||||
products: { name: string } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type AdminStop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
type AdminOrdersResult = {
|
||||
orders: AdminOrder[];
|
||||
stops: AdminStop[];
|
||||
};
|
||||
|
||||
type AdminOrdersResponse =
|
||||
| { success: true; orders: AdminOrder[]; stops: AdminStop[]; error: null }
|
||||
| { success: false; orders: []; stops: []; error: string };
|
||||
|
||||
type AdminOrderDetail = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
stop_id: string | null;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
pickup_complete: boolean;
|
||||
pickup_completed_at: string | null;
|
||||
pickup_completed_by: string | null;
|
||||
created_at: string;
|
||||
discount_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
tax_rate: number | null;
|
||||
tax_location: string | null;
|
||||
discount_reason: string | null;
|
||||
internal_notes: string | null;
|
||||
payment_processor: string | null;
|
||||
payment_status: string | null;
|
||||
payment_transaction_id: string | null;
|
||||
refunded_amount: number | null;
|
||||
refund_reason: string | null;
|
||||
stops: {
|
||||
id?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
brand_id?: string;
|
||||
location?: string;
|
||||
} | null;
|
||||
order_items?: Array<{
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fulfillment?: string;
|
||||
products: { name: string } | null;
|
||||
}>;
|
||||
refunds?: Array<{
|
||||
id: string;
|
||||
order_id: string;
|
||||
amount: number;
|
||||
reason: string | null;
|
||||
processor: string | null;
|
||||
processor_refund_id: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Mock orders for UI review
|
||||
const mockAdminOrders: AdminOrder[] = mockOrders.map((o: any) => ({
|
||||
id: o.id,
|
||||
customer_name: o.customer_name,
|
||||
customer_email: o.customer_email,
|
||||
customer_phone: o.customer_phone,
|
||||
stop_id: o.stop_id,
|
||||
status: o.status,
|
||||
subtotal: o.subtotal,
|
||||
pickup_complete: o.pickup_complete,
|
||||
pickup_completed_at: o.pickup_completed_at,
|
||||
pickup_completed_by: null,
|
||||
created_at: o.created_at,
|
||||
payment_processor: o.payment_processor,
|
||||
stops: o.stops,
|
||||
order_items: o.order_items,
|
||||
}));
|
||||
|
||||
const mockAdminStops: AdminStop[] = mockStops.map((s: any) => ({
|
||||
id: s.id,
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
date: s.date,
|
||||
location: s.location,
|
||||
brand_id: s.brand_id,
|
||||
}));
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
||||
// Return mock data in mock mode
|
||||
if (useMockData) {
|
||||
return {
|
||||
success: true,
|
||||
orders: mockAdminOrders,
|
||||
stops: mockAdminStops,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_admin_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, orders: [], stops: [], error: `HTTP ${response.status}: ${response.statusText}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
orders: data?.orders ?? [],
|
||||
stops: data?.stops ?? [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAdminStops(): Promise<AdminStop[]> {
|
||||
const result = await getAdminOrders();
|
||||
if (!result.success) return [];
|
||||
return result.stops;
|
||||
}
|
||||
|
||||
export async function getAdminPendingOrders(): Promise<AdminOrder[]> {
|
||||
const result = await getAdminOrders();
|
||||
if (!result.success) return [];
|
||||
return result.orders.filter((o) => !o.pickup_complete);
|
||||
}
|
||||
|
||||
export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
|
||||
const result = await getAdminOrders();
|
||||
if (!result.success) return [];
|
||||
return result.orders.filter((o) => o.pickup_complete);
|
||||
}
|
||||
|
||||
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
|
||||
// Return mock order detail in mock mode
|
||||
if (useMockData) {
|
||||
const order = mockAdminOrders.find((o) => o.id === orderId);
|
||||
if (!order) return null;
|
||||
return {
|
||||
...order,
|
||||
discount_amount: null,
|
||||
tax_amount: order.subtotal * 0.08,
|
||||
tax_rate: 0.08,
|
||||
tax_location: "Colorado",
|
||||
discount_reason: null,
|
||||
internal_notes: null,
|
||||
payment_status: "paid",
|
||||
payment_transaction_id: `txn_mock_${orderId}`,
|
||||
refunded_amount: 0,
|
||||
refund_reason: null,
|
||||
refunds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_order_id: orderId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
export type CreateRefundResult =
|
||||
| { success: true; id: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function createRefund(
|
||||
orderId: string,
|
||||
brandId: string | null,
|
||||
data: {
|
||||
amount: number;
|
||||
reason?: string | null;
|
||||
processor?: string | null;
|
||||
processor_refund_id?: string | null;
|
||||
}
|
||||
): Promise<CreateRefundResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/refunds`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
order_id: orderId,
|
||||
amount: data.amount,
|
||||
reason: data.reason ?? null,
|
||||
processor: data.processor ?? null,
|
||||
processor_refund_id: data.processor_refund_id ?? null,
|
||||
status: "pending",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "refunds",
|
||||
record_id: inserted[0]?.id ?? "",
|
||||
action: "INSERT",
|
||||
old_data: {},
|
||||
new_data: { order_id: orderId, amount: data.amount },
|
||||
brand_id: brandId,
|
||||
});
|
||||
|
||||
return { success: true, id: inserted[0]?.id ?? "" };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
export type UpdateOrderResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function updateOrder(
|
||||
orderId: string,
|
||||
brandId: string | null,
|
||||
data: {
|
||||
customer_name?: string;
|
||||
customer_email?: string | null;
|
||||
customer_phone?: string | null;
|
||||
status?: string;
|
||||
discount_amount?: number | null;
|
||||
discount_reason?: string | null;
|
||||
internal_notes?: string | null;
|
||||
pickup_complete?: boolean;
|
||||
pickup_completed_at?: string | null;
|
||||
subtotal?: number;
|
||||
// Payment fields
|
||||
payment_processor?: string | null;
|
||||
payment_status?: string | null;
|
||||
payment_transaction_id?: string | null;
|
||||
}
|
||||
): Promise<UpdateOrderResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const patchData: Record<string, unknown> = {};
|
||||
if (data.customer_name !== undefined) patchData.customer_name = data.customer_name;
|
||||
if (data.customer_email !== undefined) patchData.customer_email = data.customer_email;
|
||||
if (data.customer_phone !== undefined) patchData.customer_phone = data.customer_phone;
|
||||
if (data.status !== undefined) patchData.status = data.status;
|
||||
if (data.discount_amount !== undefined) patchData.discount_amount = data.discount_amount;
|
||||
if (data.discount_reason !== undefined) patchData.discount_reason = data.discount_reason;
|
||||
if (data.internal_notes !== undefined) patchData.internal_notes = data.internal_notes;
|
||||
if (data.pickup_complete !== undefined) patchData.pickup_complete = data.pickup_complete;
|
||||
if (data.pickup_completed_at !== undefined) patchData.pickup_completed_at = data.pickup_completed_at;
|
||||
if (data.subtotal !== undefined) patchData.subtotal = data.subtotal;
|
||||
if (data.payment_processor !== undefined) patchData.payment_processor = data.payment_processor;
|
||||
if (data.payment_status !== undefined) patchData.payment_status = data.payment_status;
|
||||
if (data.payment_transaction_id !== undefined) patchData.payment_transaction_id = data.payment_transaction_id;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${orderId}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patchData),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "orders",
|
||||
record_id: orderId,
|
||||
action: "UPDATE",
|
||||
old_data: {},
|
||||
new_data: patchData,
|
||||
brand_id: brandId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateOrderItem(
|
||||
itemId: string,
|
||||
data: { quantity?: number; price?: number }
|
||||
): Promise<UpdateOrderResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const patchData: Record<string, unknown> = {};
|
||||
if (data.quantity !== undefined) patchData.quantity = data.quantity;
|
||||
if (data.price !== undefined) patchData.price = data.price;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patchData),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, {
|
||||
method: "DELETE",
|
||||
headers: { ...svcHeaders(supabaseKey) },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type PaymentProvider = "stripe" | "square" | "manual";
|
||||
|
||||
export type PaymentSettings = {
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
provider: PaymentProvider | null;
|
||||
stripe_publishable_key: string | null;
|
||||
stripe_secret_key: string | null;
|
||||
square_access_token: string | null;
|
||||
square_location_id: string | null;
|
||||
square_sync_enabled: boolean;
|
||||
square_inventory_mode: "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
|
||||
square_last_sync_at: string | null;
|
||||
square_last_sync_error: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type GetPaymentSettingsResult =
|
||||
| { success: true; settings: PaymentSettings | null }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch payment settings" };
|
||||
const data = await response.json();
|
||||
return { success: true, settings: data };
|
||||
}
|
||||
|
||||
export type SavePaymentSettingsResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function savePaymentSettings(params: {
|
||||
brandId: string;
|
||||
provider: PaymentProvider | null;
|
||||
stripePublishableKey?: string;
|
||||
stripeSecretKey?: string;
|
||||
squareAccessToken?: string;
|
||||
squareLocationId?: string;
|
||||
squareSyncEnabled?: boolean;
|
||||
squareInventoryMode?: "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
|
||||
}): Promise<SavePaymentSettingsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
if (!adminUser.can_manage_orders) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_provider: params.provider,
|
||||
p_stripe_publishable_key: params.stripePublishableKey ?? null,
|
||||
p_stripe_secret_key: params.stripeSecretKey ?? null,
|
||||
p_square_access_token: params.squareAccessToken ?? null,
|
||||
p_square_location_id: params.squareLocationId ?? null,
|
||||
p_square_sync_enabled: params.squareSyncEnabled ?? null,
|
||||
p_square_inventory_mode: params.squareInventoryMode ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to save payment settings" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
type MarkPickupResult =
|
||||
| { success: true; pickup_completed_at: string; pickup_completed_by: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function markPickupComplete(
|
||||
orderId: string,
|
||||
brandId: string | null
|
||||
): Promise<MarkPickupResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
if (!adminUser.can_manage_pickup) {
|
||||
return { success: false, error: "Not authorized to manage pickup" };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const performedBy = adminUser.user_id;
|
||||
|
||||
// brand_admin: verify the order belongs to their brand
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id) {
|
||||
const brandRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`,
|
||||
{
|
||||
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
||||
}
|
||||
);
|
||||
|
||||
if (!brandRes.ok) {
|
||||
return { success: false, error: "Failed to verify order ownership" };
|
||||
}
|
||||
|
||||
const orderData = await brandRes.json();
|
||||
if (!Array.isArray(orderData) || orderData.length === 0) {
|
||||
return { success: false, error: "Order not found" };
|
||||
}
|
||||
|
||||
const order = orderData[0];
|
||||
|
||||
// Check brand_id on the order first, then fall back to stop brand
|
||||
if (order.brand_id && order.brand_id !== adminUser.brand_id) {
|
||||
return { success: false, error: "Not authorized for this order" };
|
||||
}
|
||||
|
||||
if (!order.brand_id && order.stop_id) {
|
||||
const stopRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`,
|
||||
{
|
||||
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
||||
}
|
||||
);
|
||||
|
||||
if (stopRes.ok) {
|
||||
const stopData = await stopRes.json();
|
||||
if (Array.isArray(stopData) && stopData[0]?.brand_id !== adminUser.brand_id) {
|
||||
return { success: false, error: "Not authorized for this order" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH the order
|
||||
const patchRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pickup_complete: true,
|
||||
pickup_completed_at: now,
|
||||
pickup_completed_by: performedBy,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchRes.ok) {
|
||||
const err = await patchRes.json().catch(() => ({ message: "Patch failed" }));
|
||||
return { success: false, error: err.message ?? "Failed to update pickup" };
|
||||
}
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAuditEvent({
|
||||
table_name: "orders",
|
||||
record_id: orderId,
|
||||
action: "UPDATE",
|
||||
old_data: {
|
||||
pickup_complete: false,
|
||||
pickup_completed_at: null,
|
||||
pickup_completed_by: null,
|
||||
},
|
||||
new_data: {
|
||||
pickup_complete: true,
|
||||
pickup_completed_at: now,
|
||||
pickup_completed_by: performedBy,
|
||||
},
|
||||
brand_id: brandId,
|
||||
});
|
||||
|
||||
// Emit pickup_completed event
|
||||
// Need brand_id — get it from the order we just patched
|
||||
const orderRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`,
|
||||
{
|
||||
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
||||
}
|
||||
);
|
||||
if (orderRes.ok) {
|
||||
const orderData = await orderRes.json();
|
||||
const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id;
|
||||
if (orderBrandId) {
|
||||
await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/record_pickup_completed_event`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_order_id: orderId,
|
||||
p_brand_id: orderBrandId,
|
||||
p_actor_id: performedBy,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pickup_completed_at: now,
|
||||
pickup_completed_by: performedBy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type PlatformMetrics = {
|
||||
active_brands: number;
|
||||
orders_today: number;
|
||||
revenue_today: number;
|
||||
active_routes: number;
|
||||
failed_orders_today: number;
|
||||
pending_orders_today: number;
|
||||
};
|
||||
|
||||
export type ActivityEvent = {
|
||||
id: string;
|
||||
event_type: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
payload: Record<string, unknown>;
|
||||
actor_type: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
brand_id: string | null;
|
||||
brand_name: string | null;
|
||||
};
|
||||
|
||||
export type BrandHealth = {
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
brand_slug: string;
|
||||
plan_tier: string;
|
||||
orders_today: number;
|
||||
revenue_today: number;
|
||||
active_stops: number;
|
||||
failed_orders: number;
|
||||
pending_orders: number;
|
||||
last_activity: string | null;
|
||||
open_pain_items: number;
|
||||
health_status: "healthy" | "warning" | "critical";
|
||||
};
|
||||
|
||||
export type PainLogItem = {
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
brand_name: string | null;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
category: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreatePainLogData = {
|
||||
brand_id?: string | null;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
category: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
// ── Internal fetch helper ────────────────────────────────────────────────────
|
||||
|
||||
async function platformRPC<T>(rpcName: string, body: Record<string, unknown> = {}): Promise<T> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only");
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`RPC ${rpcName} failed: ${err}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Platform actions ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPlatformMetrics(): Promise<PlatformMetrics> {
|
||||
return platformRPC<PlatformMetrics>("get_platform_command_center_metrics");
|
||||
}
|
||||
|
||||
export async function getPlatformActivityFeed(): Promise<ActivityEvent[]> {
|
||||
return platformRPC<ActivityEvent[]>("get_platform_activity_feed");
|
||||
}
|
||||
|
||||
export async function getBrandHealthSnapshot(): Promise<BrandHealth[]> {
|
||||
return platformRPC<BrandHealth[]>("get_brand_health_snapshot");
|
||||
}
|
||||
|
||||
export async function getPainLog(): Promise<PainLogItem[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/founder_pain_log_platform?select=*`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Failed to fetch pain log: ${err}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PainLogItem[]>;
|
||||
}
|
||||
|
||||
export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/founder_pain_log`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
brand_id: data.brand_id || null,
|
||||
severity: data.severity,
|
||||
category: data.category,
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: err };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePainLogItem(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/founder_pain_log?id=eq.${id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: "resolved",
|
||||
resolved_at: new Date().toISOString(),
|
||||
resolved_by: adminUser.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: err };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function deleteProduct(
|
||||
productId: string,
|
||||
brandId: string | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
if (!adminUser.can_manage_products) {
|
||||
return { success: false, error: "Not authorized to manage products" };
|
||||
}
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_product`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_product_id: productId,
|
||||
p_brand_id: effectiveBrandId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: err.message ?? "Delete failed" };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error ?? "Delete failed" };
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "products",
|
||||
record_id: productId,
|
||||
action: "DELETE",
|
||||
old_data: { deleted_at: null },
|
||||
new_data: { deleted_at: new Date().toISOString() },
|
||||
brand_id: effectiveBrandId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function logAuditEvent(event: {
|
||||
table_name: string;
|
||||
record_id: string;
|
||||
action: string;
|
||||
old_data: Record<string, unknown>;
|
||||
new_data: Record<string, unknown>;
|
||||
brand_id: string | null;
|
||||
}) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc.log_audit_event`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(event),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getMockTableData } from "@/lib/mock-data";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type CreateProductResult =
|
||||
| { success: true; id: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function createProduct(
|
||||
brandId: string,
|
||||
data: {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string | null;
|
||||
is_taxable: boolean;
|
||||
pickup_type?: string;
|
||||
}
|
||||
): Promise<CreateProductResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
const mockProducts = getMockTableData("products") as any[];
|
||||
const newId = `mock-prod-${Date.now()}`;
|
||||
const newProduct = {
|
||||
id: newId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
price: data.price,
|
||||
type: data.type,
|
||||
is_active: data.active,
|
||||
image_url: data.image_url ?? null,
|
||||
is_taxable: data.is_taxable,
|
||||
pickup_type: data.pickup_type ?? "scheduled_stop",
|
||||
brand_id: brandId,
|
||||
};
|
||||
mockProducts.push(newProduct);
|
||||
return { success: true, id: newId };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_products: [{
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
price: data.price,
|
||||
type: data.type,
|
||||
active: data.active,
|
||||
image_url: data.image_url ?? null,
|
||||
is_taxable: data.is_taxable,
|
||||
pickup_type: data.pickup_type ?? "scheduled_stop",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
return { success: false, error: result.errors[0].error };
|
||||
}
|
||||
|
||||
return { success: true, id: result.created > 0 ? "created" : "updated" };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getMockTableData } from "@/lib/mock-data";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type UpdateProductResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function updateProduct(
|
||||
productId: string,
|
||||
brandId: string,
|
||||
data: {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string | null;
|
||||
is_taxable: boolean;
|
||||
pickup_type?: string;
|
||||
}
|
||||
): Promise<UpdateProductResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
price: data.price,
|
||||
type: data.type,
|
||||
active: data.active,
|
||||
image_url: data.image_url ?? null,
|
||||
is_taxable: data.is_taxable,
|
||||
pickup_type: data.pickup_type ?? "scheduled_stop",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed to update product: ${err}` };
|
||||
}
|
||||
|
||||
const updated = await res.json();
|
||||
if (updated.errors) {
|
||||
return { success: false, error: updated.errors[0]?.message ?? "Unknown error" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type UploadProductImageResult =
|
||||
| { success: true; imageUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function uploadProductImage(
|
||||
productId: string,
|
||||
file: File
|
||||
): Promise<UploadProductImageResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return { success: false, error: "Invalid file type. Use PNG, JPEG, or WebP." };
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return { success: false, error: "File too large. Max 5MB." };
|
||||
}
|
||||
|
||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/product-images/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/product-images/${path}`;
|
||||
|
||||
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
|
||||
if (productId === "__NEW__") {
|
||||
return { success: true, imageUrl: publicUrl };
|
||||
}
|
||||
|
||||
// Update product record with new image URL
|
||||
const patchRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: publicUrl }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchRes.ok) {
|
||||
return { success: false, error: "Upload succeeded but failed to save image URL" };
|
||||
}
|
||||
|
||||
return { success: true, imageUrl: publicUrl };
|
||||
}
|
||||
|
||||
export async function deleteProductImage(
|
||||
productId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const patchRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: null }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchRes.ok) {
|
||||
return { success: false, error: "Failed to clear image" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
export type DateRange = { start: string; end: string };
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReportsSummary = {
|
||||
gross_sales: number;
|
||||
total_orders: number;
|
||||
avg_order_value: number;
|
||||
pickup_orders: number;
|
||||
shipping_orders: number;
|
||||
pending_pickups: number;
|
||||
completed_pickups: number;
|
||||
contacts_added: number;
|
||||
campaigns_sent: number;
|
||||
messages_logged: number;
|
||||
};
|
||||
|
||||
export type OrderByStop = {
|
||||
stop_name: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
order_count: number;
|
||||
gross_sales: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
};
|
||||
|
||||
export type SalesByProduct = {
|
||||
product_name: string;
|
||||
units_sold: number;
|
||||
gross_revenue: number;
|
||||
avg_price: number;
|
||||
};
|
||||
|
||||
export type FulfillmentRow = {
|
||||
fulfillment_type: string;
|
||||
order_count: number;
|
||||
revenue: number;
|
||||
pct_of_total: number;
|
||||
};
|
||||
|
||||
export type PickupStatusByStop = {
|
||||
stop_name: string;
|
||||
city: string;
|
||||
date: string;
|
||||
total_orders: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
canceled: number;
|
||||
};
|
||||
|
||||
export type ContactGrowthRow = {
|
||||
date: string;
|
||||
new_contacts: number;
|
||||
imports: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type CampaignActivityRow = {
|
||||
campaign_name: string;
|
||||
status: string;
|
||||
campaign_type: string;
|
||||
sent_at: string | null;
|
||||
messages_logged: number;
|
||||
};
|
||||
|
||||
// ── Internal fetch helper ────────────────────────────────────────────────────
|
||||
|
||||
async function reportRPC<T>(
|
||||
rpcName: string,
|
||||
params: { p_start_date: string; p_end_date: string },
|
||||
forceBrandId: string | null
|
||||
): Promise<T> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
// brand_admin: always enforce their assigned brand (ignore UI selection)
|
||||
// platform_admin: use forceBrandId (null = all brands)
|
||||
const brandId = adminUser.role === "brand_admin"
|
||||
? adminUser.brand_id
|
||||
: forceBrandId;
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_start_date: params.p_start_date,
|
||||
p_end_date: params.p_end_date,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`RPC ${rpcName} failed: ${err}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Report actions ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getReportsSummary(range: DateRange, brandId: string | null = null) {
|
||||
return reportRPC<ReportsSummary>("get_reports_summary", {
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}, brandId);
|
||||
}
|
||||
|
||||
export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) {
|
||||
return reportRPC<OrderByStop[]>("get_orders_by_stop_report", {
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}, brandId);
|
||||
}
|
||||
|
||||
export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) {
|
||||
return reportRPC<SalesByProduct[]>("get_sales_by_product_report", {
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}, brandId);
|
||||
}
|
||||
|
||||
export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) {
|
||||
return reportRPC<FulfillmentRow[]>("get_fulfillment_report", {
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}, brandId);
|
||||
}
|
||||
|
||||
export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) {
|
||||
return reportRPC<PickupStatusByStop[]>("get_pickup_status_by_stop", {
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}, brandId);
|
||||
}
|
||||
|
||||
export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) {
|
||||
return reportRPC<ContactGrowthRow[]>("get_contact_growth_report", {
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}, brandId);
|
||||
}
|
||||
|
||||
export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) {
|
||||
return reportRPC<CampaignActivityRow[]>("get_campaign_activity_report", {
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}, brandId);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
||||
|
||||
async function adminFetch(endpoint: string, options?: RequestInit) {
|
||||
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...svcHeaders(SUPABASE_PAT),
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface HarvestLot {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
variety: string | null;
|
||||
harvest_date: string;
|
||||
field_location: string | null;
|
||||
worker_name: string | null;
|
||||
packer_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
quantity_used_lbs: number | null;
|
||||
status: "active" | "in_transit" | "at_shed" | "packed" | "delivered";
|
||||
notes: string | null;
|
||||
source_stop_id: string | null;
|
||||
destination_stop_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bin_id: string | null;
|
||||
container_id: string | null;
|
||||
field_block: string | null;
|
||||
pallets: number | null;
|
||||
yield_estimate_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
}
|
||||
|
||||
export interface LotEvent {
|
||||
id: string;
|
||||
event_type: string;
|
||||
event_time: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
created_by_name: string | null;
|
||||
created_at: string;
|
||||
bin_id: string | null;
|
||||
}
|
||||
|
||||
export interface LotDetail {
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
variety: string | null;
|
||||
harvest_date: string;
|
||||
field_location: string | null;
|
||||
worker_name: string | null;
|
||||
packer_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
quantity_used_lbs: number | null;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
source_stop_id: string | null;
|
||||
destination_stop_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bin_id: string | null;
|
||||
container_id: string | null;
|
||||
field_block: string | null;
|
||||
pallets: number | null;
|
||||
yield_estimate_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
events: LotEvent[];
|
||||
}
|
||||
|
||||
export interface RouteTraceStats {
|
||||
active_count: number;
|
||||
in_transit_count: number;
|
||||
at_shed_count: number;
|
||||
total_lots_today: number;
|
||||
total_harvested_today: number;
|
||||
total_lots: number;
|
||||
}
|
||||
|
||||
export interface RecentLotEvent {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
event_time: string;
|
||||
location: string | null;
|
||||
bin_id: string | null;
|
||||
notes: string | null;
|
||||
created_by_name: string | null;
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LotOrder {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
order_date: string;
|
||||
stop_name: string;
|
||||
item_quantity: number | null;
|
||||
item_notes: string | null;
|
||||
fulfillment: string | null;
|
||||
lot_quantity_used: number | null;
|
||||
}
|
||||
|
||||
export interface TraceChain {
|
||||
lot: HarvestLot;
|
||||
events: LotEvent[];
|
||||
orders: Array<{ id: string; customer_name: string; order_date: string; stop_name: string }>;
|
||||
}
|
||||
|
||||
export interface HaulingLot {
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
harvest_date: string;
|
||||
status: string;
|
||||
field_location: string | null;
|
||||
worker_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
bin_id: string | null;
|
||||
container_id: string | null;
|
||||
field_block: string | null;
|
||||
pallets: number | null;
|
||||
destination_stop_id: string | null;
|
||||
destination_stop_name: string | null;
|
||||
destination_stop_time: string | null;
|
||||
}
|
||||
|
||||
export interface FieldYieldSummary {
|
||||
field_location: string;
|
||||
field_block: string;
|
||||
total_yield_estimate: number;
|
||||
total_quantity_lbs: number;
|
||||
active_lots: number;
|
||||
yield_unit: string | null;
|
||||
}
|
||||
|
||||
export async function getRouteTraceLots(brandId: string, status?: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_harvest_lots", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_status: status ?? null }),
|
||||
});
|
||||
return { success: true, lots: data };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRouteTraceLotDetail(lotId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_harvest_lot_detail", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_lot_id: lotId }),
|
||||
});
|
||||
if (!data || data.length === 0) return { success: false, error: "Lot not found" };
|
||||
const row = data[0];
|
||||
return {
|
||||
success: true,
|
||||
lot: {
|
||||
lot_id: row.lot_id,
|
||||
lot_number: row.lot_number,
|
||||
crop_type: row.crop_type,
|
||||
variety: row.variety ?? null,
|
||||
harvest_date: row.harvest_date,
|
||||
field_location: row.field_location,
|
||||
worker_name: row.worker_name,
|
||||
packer_name: row.packer_name ?? null,
|
||||
quantity_lbs: row.quantity_lbs,
|
||||
quantity_used_lbs: row.quantity_used_lbs ?? null,
|
||||
status: row.status,
|
||||
notes: row.notes,
|
||||
source_stop_id: row.source_stop_id,
|
||||
destination_stop_id: row.destination_stop_id,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
bin_id: row.bin_id ?? null,
|
||||
container_id: row.container_id ?? null,
|
||||
field_block: row.field_block ?? null,
|
||||
pallets: row.pallets ?? null,
|
||||
yield_estimate_lbs: row.yield_estimate_lbs ?? null,
|
||||
yield_unit: row.yield_unit ?? null,
|
||||
events: row.events ?? [],
|
||||
},
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateLotData {
|
||||
crop_type: string;
|
||||
variety?: string;
|
||||
harvest_date: string;
|
||||
field_location?: string;
|
||||
worker_name?: string;
|
||||
packer_name?: string;
|
||||
quantity_lbs?: number;
|
||||
notes?: string;
|
||||
destination_stop_id?: string;
|
||||
bin_id?: string;
|
||||
container_id?: string;
|
||||
field_block?: string;
|
||||
yield_estimate_lbs?: number;
|
||||
yield_unit?: string;
|
||||
pallets?: number;
|
||||
}
|
||||
|
||||
export async function createHarvestLot(
|
||||
brandId: string,
|
||||
data: CreateLotData
|
||||
) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const result = await adminFetch("create_harvest_lot", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_crop_type: data.crop_type,
|
||||
p_variety: data.variety ?? null,
|
||||
p_harvest_date: data.harvest_date,
|
||||
p_field_location: data.field_location ?? null,
|
||||
p_worker_name: data.worker_name ?? null,
|
||||
p_packer_name: data.packer_name ?? null,
|
||||
p_quantity_lbs: data.quantity_lbs ?? null,
|
||||
p_notes: data.notes ?? null,
|
||||
p_destination_stop_id: data.destination_stop_id ?? null,
|
||||
p_admin_id: adminUser.id ?? null,
|
||||
p_bin_id: data.bin_id ?? null,
|
||||
p_container_id: data.container_id ?? null,
|
||||
p_field_block: data.field_block ?? null,
|
||||
p_yield_estimate_lbs: data.yield_estimate_lbs ?? null,
|
||||
p_yield_unit: data.yield_unit ?? null,
|
||||
p_pallets: data.pallets ?? null,
|
||||
}),
|
||||
});
|
||||
return { success: true, lot: result };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHarvestLotStatus(
|
||||
lotId: string,
|
||||
status: string,
|
||||
location?: string,
|
||||
notes?: string,
|
||||
binId?: string
|
||||
) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const result = await adminFetch("update_harvest_lot_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
p_lot_id: lotId,
|
||||
p_status: status,
|
||||
p_location: location ?? null,
|
||||
p_notes: notes ?? null,
|
||||
p_admin_id: adminUser.id ?? null,
|
||||
...(binId ? { p_bin_id: binId } : {}),
|
||||
}),
|
||||
});
|
||||
return { success: true, lot: result };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRouteTraceStats(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_route_trace_stats", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
});
|
||||
if (!data || data.length === 0) {
|
||||
return { success: true, stats: { active_count: 0, in_transit_count: 0, at_shed_count: 0, total_lots_today: 0, total_harvested_today: 0 } };
|
||||
}
|
||||
return { success: true, stats: data[0] };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchHarvestLots(brandId: string, query: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("search_harvest_lots", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_query: query }),
|
||||
});
|
||||
return { success: true, lots: data };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTraceChain(lotId: string) {
|
||||
try {
|
||||
const data = await adminFetch("get_trace_chain", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_lot_id: lotId }),
|
||||
});
|
||||
return { success: true, chain: data };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_harvest_lots_ready_to_haul", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
});
|
||||
return { success: true, lots: data };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFieldYieldSummary(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_field_yield_summary", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
});
|
||||
return { success: true, summary: data };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLotOrders(lotId: string): Promise<{ success: true; orders: LotOrder[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_lot_orders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_lot_id: lotId }),
|
||||
});
|
||||
return { success: true, orders: data ?? [] };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface InventoryByCrop {
|
||||
crop_type: string;
|
||||
status: string;
|
||||
total_lbs: number;
|
||||
total_estimate: number;
|
||||
lot_count: number;
|
||||
yield_unit: string | null;
|
||||
}
|
||||
|
||||
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_inventory_by_crop", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
});
|
||||
return { success: true, inventory: data ?? [] };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function markLotUsedInOrder(
|
||||
lotId: string,
|
||||
orderId: string,
|
||||
quantityToAdd?: number,
|
||||
notes?: string
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
await adminFetch("mark_lot_used_in_order", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
p_lot_id: lotId,
|
||||
p_order_id: orderId,
|
||||
p_quantity_to_add: quantityToAdd ?? null,
|
||||
p_notes: notes ?? null,
|
||||
p_admin_id: adminUser.id ?? null,
|
||||
}),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentLotEvents(
|
||||
brandId: string,
|
||||
limit = 10
|
||||
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
const data = await adminFetch("get_recent_lot_events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_limit: limit }),
|
||||
});
|
||||
return { success: true, events: data ?? [] };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
export interface HarvestLot {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
variety: string | null;
|
||||
harvest_date: string;
|
||||
field_location: string | null;
|
||||
worker_name: string | null;
|
||||
packer_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
quantity_used_lbs: number | null;
|
||||
status: "active" | "in_transit" | "at_shed" | "packed" | "delivered";
|
||||
notes: string | null;
|
||||
source_stop_id: string | null;
|
||||
destination_stop_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bin_id: string | null;
|
||||
container_id: string | null;
|
||||
field_block: string | null;
|
||||
pallets: number | null;
|
||||
yield_estimate_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
}
|
||||
|
||||
export interface LotEvent {
|
||||
id: string;
|
||||
event_type: string;
|
||||
event_time: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
created_by_name: string | null;
|
||||
created_at: string;
|
||||
bin_id: string | null;
|
||||
}
|
||||
|
||||
export interface LotDetail {
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
variety: string | null;
|
||||
harvest_date: string;
|
||||
field_location: string | null;
|
||||
worker_name: string | null;
|
||||
packer_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
quantity_used_lbs: number | null;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
source_stop_id: string | null;
|
||||
destination_stop_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bin_id: string | null;
|
||||
container_id: string | null;
|
||||
field_block: string | null;
|
||||
pallets: number | null;
|
||||
yield_estimate_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
events: LotEvent[];
|
||||
}
|
||||
|
||||
export interface RouteTraceStats {
|
||||
active_count: number;
|
||||
in_transit_count: number;
|
||||
at_shed_count: number;
|
||||
total_lots_today: number;
|
||||
total_harvested_today: number;
|
||||
total_lots: number;
|
||||
}
|
||||
|
||||
export interface RecentLotEvent {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
event_time: string;
|
||||
location: string | null;
|
||||
bin_id: string | null;
|
||||
notes: string | null;
|
||||
created_by_name: string | null;
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LotOrder {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
order_date: string;
|
||||
stop_name: string;
|
||||
item_quantity: number | null;
|
||||
item_notes: string | null;
|
||||
fulfillment: string | null;
|
||||
lot_quantity_used: number | null;
|
||||
}
|
||||
|
||||
export interface TraceChain {
|
||||
lot: HarvestLot;
|
||||
events: LotEvent[];
|
||||
orders: Array<{ id: string; customer_name: string; order_date: string; stop_name: string }>;
|
||||
}
|
||||
|
||||
export interface HaulingLot {
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
harvest_date: string;
|
||||
status: string;
|
||||
field_location: string | null;
|
||||
worker_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
bin_id: string | null;
|
||||
container_id: string | null;
|
||||
field_block: string | null;
|
||||
pallets: number | null;
|
||||
destination_stop_id: string | null;
|
||||
destination_stop_name: string | null;
|
||||
destination_stop_time: string | null;
|
||||
}
|
||||
|
||||
export interface FieldYieldSummary {
|
||||
field_location: string;
|
||||
field_block: string;
|
||||
total_yield_estimate: number;
|
||||
total_quantity_lbs: number;
|
||||
active_lots: number;
|
||||
yield_unit: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryByCrop {
|
||||
crop_type: string;
|
||||
status: string;
|
||||
total_lbs: number;
|
||||
total_estimate: number;
|
||||
lot_count: number;
|
||||
yield_unit: string | null;
|
||||
}
|
||||
|
||||
export interface CreateLotData {
|
||||
crop_type: string;
|
||||
variety?: string;
|
||||
harvest_date: string;
|
||||
field_location?: string;
|
||||
worker_name?: string;
|
||||
packer_name?: string;
|
||||
quantity_lbs?: number;
|
||||
notes?: string;
|
||||
destination_stop_id?: string;
|
||||
bin_id?: string;
|
||||
container_id?: string;
|
||||
field_block?: string;
|
||||
yield_estimate_lbs?: number;
|
||||
yield_unit?: string;
|
||||
pallets?: number;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { invalidateBrandFeatureCache, type BrandFeatureKey } from "@/lib/feature-flags";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type ToggleFeatureResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function toggleBrandFeature(
|
||||
brandId: string,
|
||||
featureKey: BrandFeatureKey,
|
||||
enabled: boolean
|
||||
): Promise<ToggleFeatureResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_feature_key: featureKey,
|
||||
p_enabled: enabled,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to toggle feature" };
|
||||
}
|
||||
|
||||
invalidateBrandFeatureCache(brandId);
|
||||
revalidatePath("/admin/settings/apps");
|
||||
revalidatePath("/admin");
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type UpdateShippingStatusResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function updateShippingStatus(
|
||||
orderId: string,
|
||||
status: string,
|
||||
trackingNumber?: string
|
||||
): Promise<UpdateShippingStatusResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_order_id: orderId,
|
||||
p_shipping_status: status,
|
||||
p_tracking_number: trackingNumber ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to update shipping status" };
|
||||
const data = await response.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export type GetShippingOrdersResult = {
|
||||
success: boolean;
|
||||
orders?: ShippingOrder[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ShippingOrder = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
shipping_status: string;
|
||||
tracking_number: string | null;
|
||||
created_at: string;
|
||||
brand_id: string | null;
|
||||
order_items: Array<{
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fulfillment: string;
|
||||
products: { name: string; is_perishable: boolean } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_shipping_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch shipping orders" };
|
||||
const data = await response.json();
|
||||
return { success: true, orders: data };
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { FedExServiceType } from "./fedex-rates";
|
||||
|
||||
export type CreateShipmentResult =
|
||||
| { success: true; shipmentId: string; trackingNumber: string; labelUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||
|
||||
interface FedExAuthToken {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedToken: FedExAuthToken | null = null;
|
||||
|
||||
async function getFedExToken(settings: {
|
||||
fedexApiKey: string;
|
||||
fedexApiSecret: string;
|
||||
useProduction: boolean;
|
||||
}): Promise<{ accessToken: string } | { error: string }> {
|
||||
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
|
||||
return { accessToken: cachedToken.accessToken };
|
||||
}
|
||||
|
||||
const credentials = btoa(`${settings.fedexApiKey}:${settings.fedexApiSecret}`);
|
||||
|
||||
const res = await fetch(`${base}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: `FedEx auth failed: ${text}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
}
|
||||
|
||||
async function getFedExTokenForCreate(
|
||||
brandId: string | null,
|
||||
adminUserId: string | null
|
||||
): Promise<{ accessToken: string } | { error: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Try to get from shipping_settings
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId ?? "NULL")}&limit=1`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const settingsData: Array<{
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
}> = await settingsRes.json();
|
||||
|
||||
const settings = settingsData[0];
|
||||
if (!settings?.fedex_api_key || !settings?.fedex_api_secret) {
|
||||
return { error: "FedEx not configured for this brand" };
|
||||
}
|
||||
|
||||
return getFedExToken({
|
||||
fedexApiKey: settings.fedex_api_key,
|
||||
fedexApiSecret: settings.fedex_api_secret,
|
||||
useProduction: settings.fedex_use_production,
|
||||
});
|
||||
}
|
||||
|
||||
async function isShipmentPerishable(orderId: string): Promise<boolean> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
|
||||
{
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return !!data?.is_perishable;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* createFedExShipment — Creates a FedEx shipment for an order.
|
||||
*
|
||||
* @param orderId — The order to ship
|
||||
* @param serviceType — The FedEx service type selected (FEDEX_OVERNIGHT, etc.)
|
||||
* @param rateCharged — The rate quoted and charged (cents)
|
||||
* @param estimatedDeliveryDate — ISO date string from rate quote
|
||||
*/
|
||||
export async function createFedExShipment(
|
||||
orderId: string,
|
||||
serviceType: FedExServiceType,
|
||||
rateCharged: number, // cents
|
||||
estimatedDeliveryDate: string
|
||||
): Promise<CreateShipmentResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) {
|
||||
return { success: false, error: "Not authorized to create shipments" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Fetch order
|
||||
const orderRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" };
|
||||
const orders: Array<{
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
customer_name: string;
|
||||
customer_address: string;
|
||||
customer_city: string;
|
||||
customer_state: string;
|
||||
customer_zip: string;
|
||||
customer_country: string;
|
||||
customer_phone: string | null;
|
||||
customer_email: string | null;
|
||||
}> = await orderRes.json();
|
||||
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Get FedEx settings
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const settingsData: Array<{
|
||||
fedex_account_number: string | null;
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
refrigerated_handling_notes: string | null;
|
||||
fragile_handling_notes: string | null;
|
||||
}> = await settingsRes.json();
|
||||
|
||||
const settings = settingsData[0];
|
||||
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
|
||||
return { success: false, error: "FedEx not configured for this brand" };
|
||||
}
|
||||
|
||||
// Check perishable status
|
||||
const perishable = await isShipmentPerishable(orderId);
|
||||
|
||||
// Validate perishable-only services
|
||||
if (perishable && serviceType !== "FEDEX_OVERNIGHT" && serviceType !== "FEDEX_2_DAY_AIR") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Perishable orders can only ship via Overnight or 2-Day Air. Selected: ${serviceType}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get FedEx token
|
||||
const tokenResult = await getFedExToken({
|
||||
fedexApiKey: settings.fedex_api_key,
|
||||
fedexApiSecret: settings.fedex_api_secret,
|
||||
useProduction: settings.fedex_use_production,
|
||||
});
|
||||
|
||||
if ("error" in tokenResult) {
|
||||
return { success: false, error: tokenResult.error };
|
||||
}
|
||||
|
||||
const accessToken = tokenResult.accessToken;
|
||||
const base = settings.fedex_use_production ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Build special services
|
||||
const specialServicesRequested: string[] = [];
|
||||
if (perishable) {
|
||||
specialServicesRequested.push("REFRIGERATED");
|
||||
}
|
||||
|
||||
// Create FedEx shipment
|
||||
// NOTE: customer_address is TEXT field — may contain full address on one line.
|
||||
// For full FedEx compliance this would ideally be parsed into address_line_1/2.
|
||||
// Using it as city/state/zip for now, with customer_name as contact.
|
||||
const createShipmentRequest = {
|
||||
labelResponseOptions: "URL_ONLY",
|
||||
requestedShipment: {
|
||||
shipDateStamp: new Date().toISOString().split("T")[0],
|
||||
serviceType,
|
||||
packagingType: "YOUR_PACKAGING",
|
||||
shipper: {
|
||||
contact: {
|
||||
companyName: "Route Commerce",
|
||||
phoneNumber: "7725897500",
|
||||
emailAddress: "orders@routecommerce.com",
|
||||
},
|
||||
address: {
|
||||
streetLines: ["PO Box 1234"],
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: false,
|
||||
},
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
contact: {
|
||||
personName: order.customer_name,
|
||||
phoneNumber: order.customer_phone ?? "0000000000",
|
||||
emailAddress: order.customer_email ?? "unknown@email.com",
|
||||
},
|
||||
address: {
|
||||
streetLines: [order.customer_address || "No address provided"],
|
||||
city: order.customer_city || "Unknown",
|
||||
stateOrProvinceCode: order.customer_state || "FL",
|
||||
postalCode: order.customer_zip || "00000",
|
||||
countryCode: order.customer_country || "US",
|
||||
residential: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
shippingChargesPayment: {
|
||||
paymentType: "SENDER",
|
||||
payor: {
|
||||
responsibleParty: {
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
},
|
||||
},
|
||||
},
|
||||
specialServicesRequested: {
|
||||
specialServiceTypes: specialServicesRequested.length > 0 ? specialServicesRequested : undefined,
|
||||
...(perishable && {
|
||||
refrigeratedShipment: {
|
||||
heavyWeightNotification: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
emailNotificationDetail: {
|
||||
emailNotificationRecipientType: "RECIPIENT",
|
||||
notificationEvents: ["DELIVERED"],
|
||||
notificationFormat: "HTML",
|
||||
notificationLanguage: "en",
|
||||
recipientEmails: order.customer_email ? [order.customer_email] : [],
|
||||
},
|
||||
requestedPackageLineItems: [
|
||||
{
|
||||
weight: {
|
||||
units: "LB",
|
||||
value: 10, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time
|
||||
},
|
||||
dimensions: {
|
||||
length: 12,
|
||||
width: 8,
|
||||
height: 6,
|
||||
units: "IN",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
};
|
||||
|
||||
const shipRes = await fetch(`${base}/ship/v1/shipments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
body: JSON.stringify(createShipmentRequest),
|
||||
});
|
||||
|
||||
if (!shipRes.ok) {
|
||||
const text = await shipRes.text();
|
||||
return { success: false, error: `FedEx shipment creation failed: ${text.slice(0, 300)}` };
|
||||
}
|
||||
|
||||
const shipData = await shipRes.json();
|
||||
|
||||
const jobId = shipData?.output?.jobId;
|
||||
const trackingNumber = shipData?.output?.pieceResponses?.[0]?.trackingNumber ?? "";
|
||||
const labelUrl = shipData?.output?.pieceResponses?.[0]?.label?.url ?? "";
|
||||
const fedexShipmentId = shipData?.output?.shipmentIdentification ?? "";
|
||||
|
||||
// Store shipment record
|
||||
const insertRes = await fetch(`${supabaseUrl}/rest/v1/shipments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
order_id: orderId,
|
||||
carrier: "fedex",
|
||||
service_type: serviceType,
|
||||
tracking_number: trackingNumber,
|
||||
label_url: labelUrl,
|
||||
rate_charged: rateCharged / 100,
|
||||
estimated_delivery_date: estimatedDeliveryDate,
|
||||
is_refrigerated: perishable,
|
||||
is_fragile: false,
|
||||
handling_notes: perishable ? (settings.refrigerated_handling_notes ?? null) : (settings.fragile_handling_notes ?? null),
|
||||
status: "created",
|
||||
fedex_shipment_id: fedexShipmentId || null,
|
||||
created_by: adminUser.user_id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!insertRes.ok) {
|
||||
const errText = await insertRes.text();
|
||||
return { success: false, error: `Shipment created but failed to store record: ${errText.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const shipmentRecords: Array<{ id: string }> = await insertRes.json();
|
||||
const shipmentId = shipmentRecords[0]?.id;
|
||||
|
||||
if (!shipmentId) {
|
||||
return { success: false, error: "Shipment created but failed to retrieve shipment ID" };
|
||||
}
|
||||
|
||||
// Update order shipping_status and tracking_number
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_order_id: orderId,
|
||||
p_shipping_status: "label_created",
|
||||
p_tracking_number: trackingNumber,
|
||||
p_brand_id: effectiveBrandId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
shipmentId,
|
||||
trackingNumber,
|
||||
labelUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* FedEx Rate Quote + Shipment Creation Actions
|
||||
*
|
||||
* getFedExRates(orderId) — Fetches available FedEx rates for an order,
|
||||
* filtering service types based on perishable items.
|
||||
*
|
||||
* createFedExShipment(orderId, selectedRate, specialServices) — Creates a
|
||||
* FedEx shipment and stores the label/tracking info.
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FedExServiceType =
|
||||
| "FEDEX_OVERNIGHT"
|
||||
| "FEDEX_2_DAY_AIR"
|
||||
| "FEDEX_EXPRESS_SAVER"
|
||||
| "FEDEX_GROUND";
|
||||
|
||||
export type FedExRate = {
|
||||
serviceType: FedExServiceType;
|
||||
serviceDescription: string;
|
||||
deliveryDate: string; // ISO date string
|
||||
deliveryDayOfWeek: string;
|
||||
deliveryDateLabel: string;
|
||||
baseCharge: number; // cents
|
||||
totalCharge: number; // cents
|
||||
isPerishableOnly: boolean; // true if only available because order is all-perishable
|
||||
};
|
||||
|
||||
export type ShipmentResult =
|
||||
| { success: true; shipmentId: string; trackingNumber: string; labelUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type RateResult =
|
||||
| { success: true; rates: FedExRate[]; isPerishable: boolean; orderId: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── FedEx API Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||
|
||||
interface FedExAuthToken {
|
||||
accessToken: string;
|
||||
expiresAt: number; // epoch ms
|
||||
}
|
||||
|
||||
let cachedToken: FedExAuthToken | null = null;
|
||||
|
||||
async function getFedExToken(settings: {
|
||||
fedexApiKey: string;
|
||||
fedexApiSecret: string;
|
||||
useProduction: boolean;
|
||||
}): Promise<{ accessToken: string } | { error: string }> {
|
||||
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Reuse cached token if still valid (5 min buffer)
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
|
||||
return { accessToken: cachedToken.accessToken };
|
||||
}
|
||||
|
||||
const credentials = btoa(`${settings.fedexApiKey}:${settings.fedexApiSecret}`);
|
||||
|
||||
const res = await fetch(`${base}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: `FedEx auth failed: ${text}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
}
|
||||
|
||||
// ── Perishable Detection ──────────────────────────────────────────────────────
|
||||
|
||||
async function isOrderPerishable(
|
||||
orderId: string,
|
||||
brandId: string | null
|
||||
): Promise<boolean> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
|
||||
{
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return !!data?.is_perishable;
|
||||
}
|
||||
|
||||
// Fallback: query products directly
|
||||
const orderRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/order_items?order_id=eq.${encodeURIComponent(orderId)}&select=product_id`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderRes.ok) return false;
|
||||
|
||||
const items: { product_id: string }[] = await orderRes.json();
|
||||
if (items.length === 0) return false;
|
||||
|
||||
const productIds = items.map((i) => `id=eq.${i.product_id}`).join("&");
|
||||
const productRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?${productIds}&select=is_perishable`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!productRes.ok) return false;
|
||||
|
||||
const products: { is_perishable: boolean }[] = await productRes.json();
|
||||
return products.length > 0 && products.every((p) => p.is_perishable);
|
||||
}
|
||||
|
||||
// ── Get FedEx Rates ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFedExRates(
|
||||
orderId: string
|
||||
): Promise<RateResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) {
|
||||
return { success: false, error: "Not authorized to view shipping rates" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Fetch order + brand + address
|
||||
const orderRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*,brand:brands(id,name)`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" };
|
||||
const orders: Array<{
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
customer_address: string;
|
||||
customer_city: string;
|
||||
customer_state: string;
|
||||
customer_zip: string;
|
||||
customer_country: string;
|
||||
brand: { id: string; name: string } | null;
|
||||
}> = await orderRes.json();
|
||||
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Fetch shipping settings for this brand
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const settingsData: Array<{
|
||||
fedex_account_number: string | null;
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
}> = await settingsRes.json();
|
||||
|
||||
const settings = settingsData[0];
|
||||
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
|
||||
return { success: false, error: "FedEx not configured for this brand" };
|
||||
}
|
||||
|
||||
// Check perishable status
|
||||
const perishable = await isOrderPerishable(orderId, effectiveBrandId);
|
||||
|
||||
// Get FedEx token
|
||||
const tokenResult = await getFedExToken({
|
||||
fedexApiKey: settings.fedex_api_key,
|
||||
fedexApiSecret: settings.fedex_api_secret,
|
||||
useProduction: settings.fedex_use_production,
|
||||
});
|
||||
|
||||
if ("error" in tokenResult) {
|
||||
return { success: false, error: tokenResult.error };
|
||||
}
|
||||
|
||||
const accessToken = tokenResult.accessToken;
|
||||
const base = settings.fedex_use_production ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Determine available service types
|
||||
const ALL_SERVICES: Array<FedExServiceType> = [
|
||||
"FEDEX_OVERNIGHT",
|
||||
"FEDEX_2_DAY_AIR",
|
||||
"FEDEX_EXPRESS_SAVER",
|
||||
"FEDEX_GROUND",
|
||||
];
|
||||
|
||||
// Perishable orders: only OVERNIGHT and 2DAY
|
||||
const allowedServices = perishable
|
||||
? (["FEDEX_OVERNIGHT", "FEDEX_2_DAY_AIR"] as FedExServiceType[])
|
||||
: ALL_SERVICES;
|
||||
|
||||
// Build FedEx rate request
|
||||
// NOTE: customer_address is a TEXT field — it may be a single-line address.
|
||||
// For full FedEx compliance, address parsing would need separate address_line_1/2 fields.
|
||||
const rateRequest = {
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
requestedShipment: {
|
||||
shipper: {
|
||||
address: {
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: false,
|
||||
},
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
address: {
|
||||
city: order.customer_city || "Unknown",
|
||||
stateOrProvinceCode: order.customer_state || "FL",
|
||||
postalCode: order.customer_zip || "00000",
|
||||
countryCode: order.customer_country || "US",
|
||||
residential: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
serviceType: null, // Request all allowed services by omitting — FedEx returns all matching
|
||||
preferredServiceType: null,
|
||||
shippingChargesPayment: {
|
||||
paymentType: "SENDER",
|
||||
payor: {
|
||||
responsibleParty: {
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
},
|
||||
},
|
||||
},
|
||||
rateRequestType: ["ACCOUNT"],
|
||||
requestedPackageLineItems: [
|
||||
{
|
||||
weight: { units: "LB", value: 10 }, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const rateRes = await fetch(`${base}/rate/v1/rates`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
body: JSON.stringify(rateRequest),
|
||||
});
|
||||
|
||||
if (!rateRes.ok) {
|
||||
const text = await rateRes.text();
|
||||
return { success: false, error: `FedEx rate API error: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const rateData = await rateRes.json();
|
||||
|
||||
const rates: FedExRate[] = [];
|
||||
const output = rateData?.output?.rateReplyDetails ?? [];
|
||||
|
||||
for (const detail of output) {
|
||||
const serviceType = detail.serviceType as FedExServiceType;
|
||||
if (!allowedServices.includes(serviceType)) continue;
|
||||
|
||||
const dayOption = detail.derivedDetails?.deliveryDayOfWeek ?? "";
|
||||
const deliveryDate = detail.derivedDetails?.deliveryDate ?? "";
|
||||
const deliveryDateLabel = deliveryDate
|
||||
? new Date(deliveryDate).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: dayOption;
|
||||
|
||||
const rateDetail = detail.ratedShipmentDetails?.[0]?.shipmentRateDetail ?? {};
|
||||
const baseCharge = Math.round((rateDetail.baseCharge ?? 0) * 100);
|
||||
const totalCharge = Math.round((rateDetail.totalBaseCharge ?? rateDetail.baseCharge ?? 0) * 100);
|
||||
|
||||
const SERVICE_LABELS: Record<FedExServiceType, string> = {
|
||||
FEDEX_OVERNIGHT: "FedEx Overnight",
|
||||
FEDEX_2_DAY_AIR: "FedEx 2-Day Air",
|
||||
FEDEX_EXPRESS_SAVER: "FedEx Express Saver",
|
||||
FEDEX_GROUND: "FedEx Ground",
|
||||
};
|
||||
|
||||
rates.push({
|
||||
serviceType,
|
||||
serviceDescription: SERVICE_LABELS[serviceType] ?? serviceType,
|
||||
deliveryDate,
|
||||
deliveryDayOfWeek: dayOption,
|
||||
deliveryDateLabel,
|
||||
baseCharge,
|
||||
totalCharge,
|
||||
isPerishableOnly: perishable && (serviceType === "FEDEX_OVERNIGHT" || serviceType === "FEDEX_2_DAY_AIR"),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: overnight first, then 2day, express, ground
|
||||
const SORT_ORDER: Record<FedExServiceType, number> = {
|
||||
FEDEX_OVERNIGHT: 0,
|
||||
FEDEX_2_DAY_AIR: 1,
|
||||
FEDEX_EXPRESS_SAVER: 2,
|
||||
FEDEX_GROUND: 3,
|
||||
};
|
||||
rates.sort((a, b) => (SORT_ORDER[a.serviceType] ?? 99) - (SORT_ORDER[b.serviceType] ?? 99));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
rates,
|
||||
isPerishable: perishable,
|
||||
orderId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ShippingSettings = {
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
carrier: string;
|
||||
fedex_account_number: string | null;
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
default_service_type: string;
|
||||
refrigerated_handling_notes: string | null;
|
||||
fragile_handling_notes: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type GetShippingSettingsResult =
|
||||
| { success: true; settings: ShippingSettings | null }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type SaveShippingSettingsResult =
|
||||
| { success: true; settings: ShippingSettings }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type TestConnectionResult =
|
||||
| { success: true; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── FedEx Auth Helper (mirrors fedex-rates.ts) ─────────────────────────────────
|
||||
|
||||
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||
|
||||
interface FedExAuthToken {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedToken: FedExAuthToken | null = null;
|
||||
|
||||
async function getFedExToken(apiKey: string, apiSecret: string, useProduction: boolean): Promise<{ accessToken: string } | { error: string }> {
|
||||
const base = useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
|
||||
return { accessToken: cachedToken.accessToken };
|
||||
}
|
||||
|
||||
const credentials = btoa(`${apiKey}:${apiSecret}`);
|
||||
const res = await fetch(`${base}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: `FedEx auth failed: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
}
|
||||
|
||||
// ── Get Settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId)}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch shipping settings" };
|
||||
const data = await response.json();
|
||||
return { success: true, settings: data[0] ?? null };
|
||||
}
|
||||
|
||||
// ── Save Settings ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveShippingSettings(params: {
|
||||
brandId: string;
|
||||
fedexAccountNumber?: string;
|
||||
fedexApiKey?: string;
|
||||
fedexApiSecret?: string;
|
||||
fedexUseProduction?: boolean;
|
||||
defaultServiceType?: string;
|
||||
refrigeratedHandlingNotes?: string;
|
||||
fragileHandlingNotes?: string;
|
||||
}): Promise<SaveShippingSettingsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Get existing settings to get ID for update
|
||||
const existing = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(params.brandId)}&select=id`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const existingData: Array<{ id: string }> = await existing.json();
|
||||
const existingId = existingData[0]?.id;
|
||||
|
||||
const payload = {
|
||||
...(existingId ? { id: existingId } : {}),
|
||||
brand_id: params.brandId,
|
||||
carrier: "fedex",
|
||||
fedex_account_number: params.fedexAccountNumber ?? null,
|
||||
fedex_api_key: params.fedexApiKey ?? null,
|
||||
fedex_api_secret: params.fedexApiSecret ?? null,
|
||||
fedex_use_production: params.fedexUseProduction ?? false,
|
||||
default_service_type: params.defaultServiceType ?? "FEDEX_GROUND",
|
||||
refrigerated_handling_notes: params.refrigeratedHandlingNotes ?? null,
|
||||
fragile_handling_notes: params.fragileHandlingNotes ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings`,
|
||||
{
|
||||
method: existingId ? "PATCH" : "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { success: true, settings: data[0] };
|
||||
}
|
||||
|
||||
// ── Test Connection ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function testFedExConnection(
|
||||
fedexApiKey: string,
|
||||
fedexApiSecret: string,
|
||||
fedexUseProduction: boolean
|
||||
): Promise<TestConnectionResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const tokenResult = await getFedExToken(fedexApiKey, fedexApiSecret, fedexUseProduction);
|
||||
|
||||
if ("error" in tokenResult) {
|
||||
// Distinguish auth failure from network failure
|
||||
const msg = tokenResult.error.toLowerCase();
|
||||
if (msg.includes("401") || msg.includes("403") || msg.includes("authentication")) {
|
||||
return { success: false, error: "Invalid API key or secret. Check your FedEx Developer credentials." };
|
||||
}
|
||||
return { success: false, error: `FedEx connection failed: ${tokenResult.error.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const base = fedexUseProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Quick ping: request rates for a dummy shipment to verify token + account are valid
|
||||
const pingRes = await fetch(`${base}/rate/v1/rates`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accountNumber: { value: "000000000" }, // dummy — won't return real rates but validates auth
|
||||
requestedShipment: {
|
||||
shipper: {
|
||||
address: {
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: false,
|
||||
},
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
address: {
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
serviceType: "FEDEX_GROUND",
|
||||
shippingChargesPayment: {
|
||||
paymentType: "SENDER",
|
||||
payor: {
|
||||
responsibleParty: {
|
||||
accountNumber: { value: "000000000" },
|
||||
},
|
||||
},
|
||||
},
|
||||
rateRequestType: ["ACCOUNT"],
|
||||
requestedPackageLineItems: [
|
||||
{ weight: { units: "LB", value: 1 } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// 400 means auth worked but account/destination invalid — still a success for "credentials work"
|
||||
// 401/403 means bad credentials
|
||||
if (pingRes.status === 401 || pingRes.status === 403) {
|
||||
return { success: false, error: "Authentication failed. Verify your API key and secret are correct." };
|
||||
}
|
||||
|
||||
if (!pingRes.ok && pingRes.status !== 400) {
|
||||
const text = await pingRes.text();
|
||||
return { success: false, error: `FedEx API error: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: fedexUseProduction
|
||||
? "Connected to FedEx Production. Credentials are valid."
|
||||
: "Connected to FedEx Sandbox. Credentials are valid.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? "https://connect.squareup.com"
|
||||
: "https://connect.squareupsandbox.com";
|
||||
}
|
||||
|
||||
async function getSquareCatalogItemVariation(
|
||||
accessToken: string,
|
||||
catalogObjectId: string
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/catalog/object/${catalogObjectId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error(`Catalog object fetch failed: ${await response.text()}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function batchUpdateSquareInventory(
|
||||
accessToken: string,
|
||||
locationId: string,
|
||||
updates: Array<{
|
||||
catalogObjectId: string;
|
||||
quantity: number;
|
||||
type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT";
|
||||
}>
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/inventory/changes/batch-create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
changes: updates.map((u) => ({
|
||||
type: "ADJUSTMENT",
|
||||
physical_count: {
|
||||
catalog_object_id: u.catalogObjectId,
|
||||
location_id: locationId,
|
||||
quantity: String(u.quantity),
|
||||
measurement_unit: { quantity_unit: u.type },
|
||||
},
|
||||
occurred_at: new Date().toISOString(),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Batch inventory update failed: ${err}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export type SyncResult = {
|
||||
success: boolean;
|
||||
synced: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync inventory from Route Commerce products TO Square.
|
||||
* Reduces Square inventory for each product sold.
|
||||
* Called after an RC order is placed or updated.
|
||||
*
|
||||
* @param brandId - brand to sync for
|
||||
* @param items - array of { productId, quantity } representing sold items
|
||||
*/
|
||||
export async function syncInventoryToSquare(
|
||||
brandId: string,
|
||||
items: Array<{ productId: string; quantity: number }>
|
||||
): Promise<SyncResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
|
||||
const settingsResult = await getPaymentSettings(brandId);
|
||||
if (!settingsResult.success || !settingsResult.settings) {
|
||||
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
|
||||
}
|
||||
const settings = settingsResult.settings;
|
||||
|
||||
if (
|
||||
!settings.square_access_token ||
|
||||
!settings.square_location_id ||
|
||||
!settings.square_sync_enabled ||
|
||||
!["rc_to_square", "bidirectional"].includes(settings.square_inventory_mode)
|
||||
) {
|
||||
return { success: true, synced: 0, errors: [] }; // Not enabled, no-op
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const synced = 0;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Build product name → quantity map
|
||||
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
|
||||
|
||||
try {
|
||||
// Fetch product details from RC
|
||||
const productIds = items.map((i) => i.productId);
|
||||
const productsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?id=in.(${productIds.join(",")})&select=id,name`,
|
||||
{
|
||||
headers: { ...svcHeaders(supabaseKey) },
|
||||
}
|
||||
);
|
||||
if (!productsRes.ok) {
|
||||
return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] };
|
||||
}
|
||||
const products: Array<{ id: string; name: string }> = await productsRes.json();
|
||||
|
||||
// Find Square catalog items matching product names and reduce quantity
|
||||
const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = [];
|
||||
|
||||
for (const product of products) {
|
||||
const qty = itemQtyMap.get(product.id) ?? 0;
|
||||
if (qty <= 0) continue;
|
||||
|
||||
// In a real implementation, we'd maintain a mapping of RC product ID → Square catalog object ID
|
||||
// For now, we skip this without the mapping (requires manual catalog linking)
|
||||
// A future improvement would store square_catalog_object_id on RC products table
|
||||
errors.push(`Product "${product.name}": inventory sync requires Square catalog object ID mapping (not yet implemented)`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Inventory sync error: ${String(err)}`);
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, synced, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync inventory from Square TO Route Commerce (polling).
|
||||
* Fetches Square inventory counts and updates RC product inventory.
|
||||
*/
|
||||
export async function syncInventoryFromSquare(brandId: string): Promise<SyncResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
}
|
||||
|
||||
const settingsResult = await getPaymentSettings(brandId);
|
||||
if (!settingsResult.success || !settingsResult.settings) {
|
||||
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
|
||||
}
|
||||
const settings = settingsResult.settings;
|
||||
|
||||
if (
|
||||
!settings.square_access_token ||
|
||||
!settings.square_location_id ||
|
||||
!settings.square_sync_enabled ||
|
||||
!["square_to_rc", "bidirectional"].includes(settings.square_inventory_mode)
|
||||
) {
|
||||
return { success: true, synced: 0, errors: [] }; // Not enabled, no-op
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
try {
|
||||
// Fetch Square inventory counts for the location
|
||||
const baseUrl = getSquareBaseUrl(settings.square_access_token);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/inventory/${settings.square_location_id}/counts`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.square_access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, synced: 0, errors: [`Square inventory fetch failed: ${await response.text()}`] };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// For each inventory count, update RC product if we have a mapping
|
||||
for (const count of data.counts ?? []) {
|
||||
// count.catalog_object_id, count.quantity, count.calculated_at
|
||||
// Would need a mapping table to update correct RC product
|
||||
// This is noted as a future enhancement
|
||||
errors.push(`Inventory item ${count.catalog_object_id}: requires Square catalog object ID mapping`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Inventory sync error: ${String(err)}`);
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, synced, errors };
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? "https://connect.squareup.com"
|
||||
: "https://connect.squareupsandbox.com";
|
||||
}
|
||||
|
||||
async function fetchSquarePayments(
|
||||
accessToken: string,
|
||||
locationId: string,
|
||||
since: string
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/payments/list`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
location_id: locationId,
|
||||
begin_time: since,
|
||||
order: "ASC",
|
||||
limit: 100,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Square payments list failed: ${err}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchSquareOrder(accessToken: string, orderId: string) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/orders/${orderId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Square order fetch failed: ${err}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export type SyncResult = {
|
||||
success: boolean;
|
||||
synced: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
}
|
||||
|
||||
const settingsResult = await getPaymentSettings(brandId);
|
||||
if (!settingsResult.success || !settingsResult.settings) {
|
||||
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
|
||||
}
|
||||
const settings = settingsResult.settings;
|
||||
|
||||
if (!settings.square_access_token || !settings.square_location_id) {
|
||||
return { success: false, synced: 0, errors: ["Square not connected or no location"] };
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Determine sync start time — last sync or 30 days ago
|
||||
const since = settings.square_last_sync_at
|
||||
? settings.square_last_sync_at
|
||||
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
try {
|
||||
const paymentsData = await fetchSquarePayments(
|
||||
settings.square_access_token,
|
||||
settings.square_location_id,
|
||||
since
|
||||
);
|
||||
|
||||
for (const payment of paymentsData.payments ?? []) {
|
||||
try {
|
||||
// Skip if no order_id
|
||||
if (!payment.order_id) continue;
|
||||
// Skip if already completed/canceled
|
||||
if (!["COMPLETED", "APPROVED"].includes(payment.status ?? "")) continue;
|
||||
|
||||
const orderData = await fetchSquareOrder(settings.square_access_token, payment.order_id);
|
||||
const order = orderData.order;
|
||||
|
||||
// Build line items from Square order
|
||||
const lineItems = (order.line_items ?? []).map((li: {
|
||||
name: string;
|
||||
quantity: string;
|
||||
base_price_money: { amount: string; currency: string };
|
||||
}) => ({
|
||||
name: li.name,
|
||||
quantity: parseInt(li.quantity, 10),
|
||||
price: Number(li.base_price_money?.amount ?? 0) / 100,
|
||||
}));
|
||||
|
||||
// Compute total
|
||||
const total = Number(order.total_money?.amount ?? payment.amount_money?.amount ?? 0) / 100;
|
||||
|
||||
// Get customer info from payment
|
||||
const customerName = payment.customer_id ?? "Square Customer";
|
||||
const customerEmail = payment.receipt_number ?? "";
|
||||
|
||||
// Use idempotency key to avoid duplicates
|
||||
const idempotencyKey = `square_${payment.id}`;
|
||||
|
||||
const createRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_customer_name: customerName,
|
||||
p_customer_email: customerEmail,
|
||||
p_customer_phone: "",
|
||||
p_stop_id: null, // Square orders don't have RC stop_id
|
||||
p_items: lineItems.map((li: { name: string; quantity: number; price: number }) => ({
|
||||
id: null, // product lookup not available in this flow
|
||||
quantity: li.quantity,
|
||||
fulfillment: "shipping",
|
||||
})),
|
||||
p_subtotal: total,
|
||||
p_payment_processor: "square",
|
||||
p_payment_status: "paid",
|
||||
p_payment_transaction_id: payment.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (createRes.ok || createRes.status === 409) {
|
||||
// 409 = already exists (idempotent)
|
||||
synced++;
|
||||
} else {
|
||||
const errText = await createRes.text();
|
||||
errors.push(`Payment ${payment.id}: ${errText.slice(0, 100)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Payment ${payment.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Sync error: ${String(err)}`);
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, synced, errors };
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type SquareCatalogItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
type: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
function getSquareClient(accessToken: string) {
|
||||
const env = process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? SquareEnvironment.Production
|
||||
: SquareEnvironment.Sandbox;
|
||||
|
||||
const config: BaseClientOptions = {
|
||||
token: accessToken,
|
||||
environment: env,
|
||||
};
|
||||
return new SquareClient(config);
|
||||
}
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
const env = process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? "https://connect.squareup.com"
|
||||
: "https://connect.squareupsandbox.com";
|
||||
return env;
|
||||
}
|
||||
|
||||
async function fetchSquareCatalog(accessToken: string, cursor?: string) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/catalog/list`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
types: ["ITEM"],
|
||||
...(cursor ? { cursor } : {}),
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Square catalog list failed: ${err}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function upsertSquareCatalogItem(
|
||||
accessToken: string,
|
||||
item: {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
imageUrl?: string;
|
||||
},
|
||||
existingItemId?: string
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const body: Record<string, unknown> = {
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
item: {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
variations: [
|
||||
{
|
||||
name: "Default",
|
||||
pricing_type: "FIXED_PRICING",
|
||||
price_money: {
|
||||
amount: BigInt(Math.round(item.price * 100)),
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (existingItemId) {
|
||||
body.item = { ...body.item as object, id: existingItemId };
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v2/catalog/object`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Square upsert failed: ${err}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export type SyncResult = {
|
||||
success: boolean;
|
||||
synced: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export async function syncProductsToSquare(brandId: string): Promise<SyncResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
}
|
||||
|
||||
const settingsResult = await getPaymentSettings(brandId);
|
||||
if (!settingsResult.success || !settingsResult.settings) {
|
||||
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
|
||||
}
|
||||
const settings = settingsResult.settings;
|
||||
|
||||
if (!settings.square_access_token) {
|
||||
return { success: false, synced: 0, errors: ["Square not connected"] };
|
||||
}
|
||||
|
||||
const client = getSquareClient(settings.square_access_token);
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
|
||||
try {
|
||||
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query)
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const rpcRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!rpcRes.ok) {
|
||||
const errText = await rpcRes.text();
|
||||
return { success: false, synced: 0, errors: [`Failed to fetch wholesale products: ${errText}`] };
|
||||
}
|
||||
|
||||
const products: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
availability: string;
|
||||
unit_type: string;
|
||||
price_tiers: Array<{ price: number; min_qty: number; max_qty: number | null }>;
|
||||
hp_sku: string | null;
|
||||
hp_item_id: string | null;
|
||||
default_pickup_location: string | null;
|
||||
}> = await rpcRes.json();
|
||||
|
||||
// Filter to available products only
|
||||
const availableProducts = products.filter((p) => p.availability === "available");
|
||||
if (availableProducts.length === 0) {
|
||||
return { success: true, synced: 0, errors: ["No available products to sync"] };
|
||||
}
|
||||
|
||||
// Build map of existing Square catalog items by hp_item_id (primary) and name (fallback)
|
||||
const existingByItemId = new Map<string, string>();
|
||||
const existingByName = new Map<string, string>();
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const catalogData = await fetchSquareCatalog(settings.square_access_token, cursor);
|
||||
for (const obj of catalogData.objects ?? []) {
|
||||
if (obj.type === "ITEM" && obj.item?.name) {
|
||||
existingByName.set(obj.item.name, obj.id);
|
||||
// Store item by its catalog object ID (used for upserts)
|
||||
if (obj.id) existingByItemId.set(obj.id, obj.id);
|
||||
}
|
||||
}
|
||||
cursor = catalogData.cursor;
|
||||
} while (cursor);
|
||||
|
||||
// Upsert each available wholesale product into Square
|
||||
for (const product of availableProducts) {
|
||||
try {
|
||||
// Primary: match by hp_item_id (Square catalog object ID we stored previously)
|
||||
// Secondary: match by exact name if hp_item_id not set
|
||||
let existingId = product.hp_item_id
|
||||
? existingByItemId.get(product.hp_item_id) ?? undefined
|
||||
: undefined;
|
||||
|
||||
if (!existingId && !product.hp_item_id) {
|
||||
// Fall back to name-based match (deduped by name)
|
||||
existingId = existingByName.get(product.name);
|
||||
}
|
||||
|
||||
// Derive price from first price tier, default to 0
|
||||
const price = product.price_tiers?.[0]?.price ?? 0;
|
||||
|
||||
// Build description with unit/pack info
|
||||
const unitInfo = product.unit_type ? ` [Unit: ${product.unit_type}]` : "";
|
||||
const pickupInfo = product.default_pickup_location
|
||||
? ` | Pickup: ${product.default_pickup_location}`
|
||||
: "";
|
||||
const description = `${product.description ?? ""}${unitInfo}${pickupInfo}`.trim();
|
||||
|
||||
await upsertSquareCatalogItem(settings.square_access_token, {
|
||||
name: product.name,
|
||||
description,
|
||||
price,
|
||||
}, existingId);
|
||||
synced++;
|
||||
} catch (err) {
|
||||
errors.push(`"${product.name}": ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Sync error: ${String(err)}`);
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, synced, errors };
|
||||
}
|
||||
|
||||
export async function syncProductsFromSquare(brandId: string): Promise<SyncResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
}
|
||||
|
||||
const settingsResult = await getPaymentSettings(brandId);
|
||||
if (!settingsResult.success || !settingsResult.settings) {
|
||||
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
|
||||
}
|
||||
const settings = settingsResult.settings;
|
||||
|
||||
if (!settings.square_access_token) {
|
||||
return { success: false, synced: 0, errors: ["Square not connected"] };
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
try {
|
||||
const catalogData = await fetchSquareCatalog(settings.square_access_token, cursor);
|
||||
|
||||
for (const obj of catalogData.objects ?? []) {
|
||||
if (obj.type !== "ITEM" || !obj.item?.name) continue;
|
||||
const item = obj.item;
|
||||
const variations = item.variations ?? [];
|
||||
const priceMoney = variations[0]?.price_money;
|
||||
const price = priceMoney ? Number(priceMoney.amount) / 100 : 0;
|
||||
const imageUrl = obj.item.image_url ?? null;
|
||||
|
||||
// Sync to RC via bulk_upsert_products RPC
|
||||
const upsertRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_products: [
|
||||
{
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
price,
|
||||
type: "Pickup & Shipping",
|
||||
active: true,
|
||||
image_url: imageUrl,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (upsertRes.ok) {
|
||||
synced++;
|
||||
} else {
|
||||
const errText = await upsertRes.text();
|
||||
errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = catalogData.cursor;
|
||||
} catch (err) {
|
||||
errors.push(`Catalog batch error: ${String(err)}`);
|
||||
break;
|
||||
}
|
||||
} while (cursor);
|
||||
|
||||
return { success: errors.length === 0, synced, errors };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type SyncLogEntry = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
event_type: string;
|
||||
direction: string | null;
|
||||
entity_type: string | null;
|
||||
entity_id: string | null;
|
||||
status: string;
|
||||
message: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SyncResult = {
|
||||
success: boolean;
|
||||
synced: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export async function syncSquareNow(
|
||||
brandId: string,
|
||||
type: "products" | "orders" | "all"
|
||||
): Promise<SyncResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/square/sync`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ brandId, type }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
return { success: false, synced: 0, errors: [`HTTP ${response.status}: ${errText}`] };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
synced: result.synced ?? 0,
|
||||
errors: result.errors ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSyncLog(brandId: string): Promise<{
|
||||
success: boolean;
|
||||
logs: SyncLogEntry[];
|
||||
}> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, logs: [] };
|
||||
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, logs: [] };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, logs: [] };
|
||||
}
|
||||
|
||||
const logs: SyncLogEntry[] = await response.json();
|
||||
return { success: true, logs };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type StopImportRow = {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address?: string;
|
||||
zip?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export async function createStopsBatch(
|
||||
brandId: string,
|
||||
stops: StopImportRow[]
|
||||
): Promise<{ success: boolean; created: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, created: 0, error: "Not authorized to manage stops" };
|
||||
}
|
||||
|
||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||
if (!effectiveBrandId) {
|
||||
return { success: false, created: 0, error: "No brand selected" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const rows = stops.map((s) => {
|
||||
const slug = `${s.city.toLowerCase().replace(/\s+/g, "-")}-${s.date || new Date().toISOString().slice(0, 10)}`;
|
||||
return {
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
location: s.location,
|
||||
date: s.date || "",
|
||||
time: s.time || "",
|
||||
address: s.address || null,
|
||||
zip: s.zip || null,
|
||||
brand_id: effectiveBrandId,
|
||||
slug,
|
||||
status: "draft",
|
||||
active: false,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify(rows),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
|
||||
}
|
||||
|
||||
export async function publishStop(
|
||||
stopId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "active", active: true }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Patch failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { getMockTableData } from "@/lib/mock-data";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type CreateStopResult =
|
||||
| { success: true; id: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function createStop(
|
||||
brandId: string,
|
||||
data: {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
active?: boolean;
|
||||
}
|
||||
): Promise<CreateStopResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
const mockStops = getMockTableData("stops") as Array<{ id: string }>;
|
||||
return { success: true, id: `mock-stop-${Date.now()}` };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date || new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
location: data.location,
|
||||
date: data.date,
|
||||
time: data.time,
|
||||
slug,
|
||||
brand_id: brandId,
|
||||
active: data.active ?? false,
|
||||
address: data.address ?? null,
|
||||
zip: data.zip ?? null,
|
||||
cutoff_time: data.cutoff_time ?? null,
|
||||
status: "draft",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
return { success: true, id: inserted[0]?.id ?? "" };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type UpdateStopResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function updateStop(
|
||||
stopId: string,
|
||||
brandId: string,
|
||||
data: {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
active: boolean;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
}
|
||||
): Promise<UpdateStopResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
location: data.location,
|
||||
date: data.date,
|
||||
time: data.time,
|
||||
slug,
|
||||
active: data.active,
|
||||
brand_id: brandId,
|
||||
address: data.address ?? null,
|
||||
zip: data.zip ?? null,
|
||||
cutoff_time: data.cutoff_time ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "stops",
|
||||
record_id: stopId,
|
||||
action: "UPDATE",
|
||||
old_data: {},
|
||||
new_data: { city: data.city, state: data.state, date: data.date, active: data.active },
|
||||
brand_id: brandId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use server";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type TaxCalculationResult = {
|
||||
taxAmount: number;
|
||||
taxRate: number;
|
||||
taxLocation: string;
|
||||
};
|
||||
|
||||
type BrandTaxSettings = {
|
||||
collect_sales_tax: boolean | null;
|
||||
nexus_states: string[] | null;
|
||||
};
|
||||
|
||||
export async function calculateOrderTax(params: {
|
||||
brandId: string;
|
||||
subtotal: number;
|
||||
items: Array<{ id: string; quantity: number; price: number; name?: string; is_taxable?: boolean }>;
|
||||
fulfillment: "pickup" | "ship";
|
||||
shippingAddress?: { state: string; postal_code: string; city?: string };
|
||||
}): Promise<TaxCalculationResult> {
|
||||
// 1. If not collecting tax, return zero
|
||||
const taxSettings = await getBrandTaxSettings(params.brandId);
|
||||
if (!taxSettings?.collect_sales_tax) {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: "" };
|
||||
}
|
||||
|
||||
// 2. Pickup orders are not taxable in most states
|
||||
if (params.fulfillment === "pickup") {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: "" };
|
||||
}
|
||||
|
||||
// 3. Must have a shipping address in nexus states
|
||||
if (!params.shippingAddress?.state) {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: "" };
|
||||
}
|
||||
|
||||
const nexus = taxSettings.nexus_states ?? [];
|
||||
const shipState = params.shippingAddress.state.toUpperCase();
|
||||
|
||||
if (!nexus.map((s) => s.toUpperCase()).includes(shipState)) {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: "" };
|
||||
}
|
||||
|
||||
// 4. Stripe Tax calculation
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: "" };
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = new Stripe(stripeKey);
|
||||
|
||||
// Build Stripe line items for tax calculation — only taxable items
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lineItems: any[] = params.items
|
||||
.filter((item) => item.is_taxable !== false)
|
||||
.map((item) => ({
|
||||
amount: Math.round(item.price * item.quantity * 100), // cents
|
||||
reference: item.id,
|
||||
tax_behavior: "exclusive",
|
||||
}));
|
||||
|
||||
// If no taxable items, return zero tax
|
||||
if (lineItems.length === 0) {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: `STATE:${shipState}` };
|
||||
}
|
||||
|
||||
const calculation = await stripe.tax.calculations.create({
|
||||
currency: "usd",
|
||||
line_items: lineItems,
|
||||
customer_details: {
|
||||
address: {
|
||||
state: shipState,
|
||||
postal_code: params.shippingAddress.postal_code,
|
||||
city: params.shippingAddress.city,
|
||||
country: "US",
|
||||
},
|
||||
address_source: "shipping",
|
||||
},
|
||||
});
|
||||
|
||||
if (!calculation.tax_amount_exclusive) {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: `STATE:${shipState}` };
|
||||
}
|
||||
|
||||
const taxAmount = calculation.tax_amount_exclusive / 100;
|
||||
|
||||
// Derive effective rate from subtotal
|
||||
const taxRate = params.subtotal > 0 ? taxAmount / params.subtotal : 0;
|
||||
|
||||
return {
|
||||
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||
taxRate: Math.round(taxRate * 10000) / 10000,
|
||||
taxLocation: `STATE:${shipState}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { taxAmount: 0, taxRate: 0, taxLocation: "" };
|
||||
}
|
||||
}
|
||||
|
||||
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return {
|
||||
collect_sales_tax: data?.collect_sales_tax ?? null,
|
||||
nexus_states: data?.nexus_states ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
function rpcBody(body: Record<string, unknown>) {
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeTrackingSession = {
|
||||
worker_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
lang: string;
|
||||
session_id: string;
|
||||
brand_id: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
// ── Cookie helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_COOKIE = "time_tracking_session";
|
||||
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
|
||||
|
||||
function sessionCookie(session: TimeTrackingSession) {
|
||||
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
|
||||
}
|
||||
|
||||
function parseSessionCookie(cookie: string): TimeTrackingSession | null {
|
||||
const parts = cookie.split("|");
|
||||
if (parts.length !== 3) return null;
|
||||
const [worker_id, session_id, expires_at] = parts;
|
||||
if (new Date(expires_at) < new Date()) return null;
|
||||
return {
|
||||
worker_id,
|
||||
name: "",
|
||||
role: "worker",
|
||||
lang: "en",
|
||||
session_id,
|
||||
brand_id: "",
|
||||
expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Verify PIN ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function verifyTimeTrackingPin(
|
||||
brandId: string,
|
||||
pin: string
|
||||
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/verify_time_tracking_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({ p_brand_id: brandId, p_pin: pin }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Invalid PIN" };
|
||||
}
|
||||
|
||||
const session: TimeTrackingSession = {
|
||||
worker_id: data.worker_id,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
lang: data.lang,
|
||||
session_id: data.session_id,
|
||||
brand_id: data.brand_id,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
|
||||
// Set HTTP-only cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, sessionCookie(session), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { success: true, session };
|
||||
}
|
||||
|
||||
// ── Clock In ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function clockInWorker(
|
||||
brandId: string,
|
||||
taskId?: string,
|
||||
taskName = "General Labor"
|
||||
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/clock_in_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({
|
||||
p_brand_id: brandId,
|
||||
p_worker_id: session.worker_id,
|
||||
p_task_id: taskId ?? null,
|
||||
p_task_name: taskName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, log_id: data.log_id, clock_in: data.clock_in };
|
||||
}
|
||||
|
||||
// ── Clock Out ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function clockOutWorker(
|
||||
lunchMinutes = 0,
|
||||
notes?: string
|
||||
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/clock_out_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({
|
||||
p_worker_id: session.worker_id,
|
||||
p_lunch_minutes: lunchMinutes,
|
||||
p_notes: notes ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return {
|
||||
success: true,
|
||||
log_id: data.log_id,
|
||||
clock_out: data.clock_out,
|
||||
total_minutes: data.total_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Get Open Clock In ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getOpenClockIn(
|
||||
brandId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
open?: boolean;
|
||||
log_id?: string;
|
||||
task_name?: string;
|
||||
clock_in?: string;
|
||||
elapsed_minutes?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_open_clock_in`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({ p_worker_id: session.worker_id }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return {
|
||||
success: true,
|
||||
open: data.open,
|
||||
log_id: data.log_id,
|
||||
task_name: data.task_name,
|
||||
clock_in: data.clock_in,
|
||||
elapsed_minutes: data.elapsed_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function logoutTimeTracking(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
// ── Get Current Session ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get(SESSION_COOKIE);
|
||||
if (!cookie) return null;
|
||||
return parseSessionCookie(cookie.value);
|
||||
}
|
||||
|
||||
// ── Get Tasks (for task picker) ─────────────────────────────────────────────────
|
||||
|
||||
export type TimeTaskField = {
|
||||
id: string;
|
||||
name: string;
|
||||
name_es: string | null;
|
||||
unit: string;
|
||||
active: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
export async function getTimeTrackingTasksField(
|
||||
brandId: string,
|
||||
activeOnly = true
|
||||
): Promise<TimeTaskField[]> {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.tasks ?? [];
|
||||
}
|
||||
|
||||
// ── Pay Period Hours ───────────────────────────────────────────────────────────
|
||||
|
||||
export type PayPeriodHours = {
|
||||
success: boolean;
|
||||
total_minutes: number;
|
||||
total_hours: number;
|
||||
daily_minutes: number;
|
||||
daily_hours: number;
|
||||
weekly_minutes: number;
|
||||
weekly_hours: number;
|
||||
daily_overtime: boolean;
|
||||
weekly_overtime: boolean;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
daily_threshold: number;
|
||||
weekly_threshold: number;
|
||||
};
|
||||
|
||||
export async function getWorkerPayPeriodHours(
|
||||
brandId: string
|
||||
): Promise<PayPeriodHours> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 };
|
||||
|
||||
const [hoursRes, settingsRes] = await Promise.all([
|
||||
fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_worker_pay_period_hours`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
|
||||
body: rpcBody({ p_brand_id: brandId, p_worker_id: session.worker_id }),
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
|
||||
body: rpcBody({ p_brand_id: brandId }),
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
const data = await hoursRes.json();
|
||||
const settings = settingsRes.ok ? (await settingsRes.json()) : null;
|
||||
|
||||
if (!hoursRes.ok) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 };
|
||||
|
||||
return {
|
||||
...data,
|
||||
daily_threshold: settings ? Number(settings.daily_overtime_threshold) : 12,
|
||||
weekly_threshold: settings ? Number(settings.weekly_overtime_threshold) : 56,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Mock mode flag - only enabled when explicitly set
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
function rpcBody(body: Record<string, unknown>) {
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeWorker = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
lang: string;
|
||||
active: boolean;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TimeTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
name_es: string | null;
|
||||
unit: string;
|
||||
active: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
export type TimeLog = {
|
||||
id: string;
|
||||
worker_id: string;
|
||||
worker_name: string;
|
||||
task_id: string | null;
|
||||
task_name: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
lunch_break_minutes: number;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
total_minutes: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TimeSummary = {
|
||||
by_worker: { id: string; name: string; entry_count: number; total_hours: number }[];
|
||||
by_task: { id: string; name: string; name_es: string | null; entry_count: number; total_hours: number }[];
|
||||
totals: { entry_count: number; total_hours: number; open_count: number };
|
||||
};
|
||||
|
||||
// ── Workers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorker[]> {
|
||||
if (useMockData) {
|
||||
return mockWorkers.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
lang: w.language,
|
||||
active: w.is_active,
|
||||
last_used_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_workers`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.workers ?? [];
|
||||
}
|
||||
|
||||
export async function createTimeWorker(
|
||||
brandId: string,
|
||||
name: string,
|
||||
role = "worker",
|
||||
lang = "en"
|
||||
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_time_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_name: name, p_role: role, p_lang: lang }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, worker: data.worker, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function resetTimeWorkerPin(workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/reset_time_worker_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_worker_id: workerId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function updateTimeWorker(
|
||||
workerId: string,
|
||||
name: string,
|
||||
role: string,
|
||||
lang: string,
|
||||
active: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_time_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_worker_id: workerId, p_name: name, p_role: role, p_lang: lang, p_active: active }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeWorker(workerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_time_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_worker_id: workerId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingTasks(brandId: string, activeOnly = false): Promise<TimeTask[]> {
|
||||
if (useMockData) {
|
||||
return mockTasks.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name_en,
|
||||
name_es: t.name_es,
|
||||
unit: t.unit,
|
||||
active: true,
|
||||
sort_order: t.sort_order,
|
||||
}));
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.tasks ?? [];
|
||||
}
|
||||
|
||||
export async function createTimeTask(
|
||||
brandId: string,
|
||||
name: string,
|
||||
nameEs: string | null = null,
|
||||
unit = "hours",
|
||||
sortOrder = 0
|
||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_time_task`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_name: name, p_name_es: nameEs, p_unit: unit, p_sort_order: sortOrder }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, id: data.id };
|
||||
}
|
||||
|
||||
export async function updateTimeTask(
|
||||
taskId: string,
|
||||
name: string,
|
||||
nameEs: string,
|
||||
unit: string,
|
||||
active: boolean,
|
||||
sortOrder: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_time_task`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_task_id: taskId, p_name: name, p_name_es: nameEs, p_unit: unit, p_active: active, p_sort_order: sortOrder }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeTask(taskId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_time_task`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_task_id: taskId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Time Logs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWorkerTimeLogs(
|
||||
brandId: string,
|
||||
options: {
|
||||
workerId?: string;
|
||||
taskId?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
): Promise<TimeLog[]> {
|
||||
if (useMockData) {
|
||||
// Filter by worker, task, date range
|
||||
let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
||||
if (options.workerId) entries = entries.filter(e => e.worker_id === options.workerId);
|
||||
|
||||
return entries.map(e => {
|
||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
||||
const task = mockTasks.find(t => t.id === e.task_id);
|
||||
return {
|
||||
id: e.id,
|
||||
worker_id: e.worker_id,
|
||||
worker_name: worker?.name ?? "Unknown",
|
||||
task_id: e.task_id,
|
||||
task_name: task?.name_en ?? "Unknown",
|
||||
clock_in: `${e.date}T09:00:00Z`,
|
||||
clock_out: `${e.date}T${9 + e.hours}:00:00Z`,
|
||||
lunch_break_minutes: 0,
|
||||
notes: null,
|
||||
submitted_via: "web",
|
||||
total_minutes: Math.round(e.hours * 60),
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_worker_time_logs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({
|
||||
p_brand_id: brandId,
|
||||
p_worker_id: options.workerId ?? null,
|
||||
p_task_id: options.taskId ?? null,
|
||||
p_start: options.start ?? null,
|
||||
p_end: options.end ?? null,
|
||||
p_limit: options.limit ?? 200,
|
||||
p_offset: options.offset ?? 0,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.logs ?? [];
|
||||
}
|
||||
|
||||
export async function updateWorkerTimeLog(
|
||||
logId: string,
|
||||
taskName: string,
|
||||
clockIn: string,
|
||||
clockOut: string | null,
|
||||
lunchMinutes: number,
|
||||
notes: string | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_worker_time_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({
|
||||
p_log_id: logId,
|
||||
p_task_name: taskName,
|
||||
p_clock_in: clockIn,
|
||||
p_clock_out: clockOut,
|
||||
p_lunch_minutes: lunchMinutes,
|
||||
p_notes: notes,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWorkerTimeLog(logId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_worker_time_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_log_id: logId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getTimeTrackingSummary(
|
||||
brandId: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<TimeSummary> {
|
||||
if (useMockData) {
|
||||
const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
||||
|
||||
// Calculate by worker
|
||||
const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>();
|
||||
entries.forEach(e => {
|
||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
||||
const existing = workerMap.get(e.worker_id) || { id: e.worker_id, name: worker?.name ?? "Unknown", total_hours: 0, entry_count: 0 };
|
||||
existing.total_hours += e.hours;
|
||||
existing.entry_count += 1;
|
||||
workerMap.set(e.worker_id, existing);
|
||||
});
|
||||
|
||||
// Calculate by task
|
||||
const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>();
|
||||
entries.forEach(e => {
|
||||
const task = mockTasks.find(t => t.id === e.task_id);
|
||||
const existing = taskMap.get(e.task_id) || { id: e.task_id, name: task?.name_en ?? "Unknown", name_es: task?.name_es ?? null, total_hours: 0, entry_count: 0 };
|
||||
existing.total_hours += e.hours;
|
||||
existing.entry_count += 1;
|
||||
taskMap.set(e.task_id, existing);
|
||||
});
|
||||
|
||||
return {
|
||||
by_worker: Array.from(workerMap.values()),
|
||||
by_task: Array.from(taskMap.values()),
|
||||
totals: {
|
||||
entry_count: entries.length,
|
||||
total_hours: entries.reduce((sum, e) => sum + e.hours, 0),
|
||||
open_count: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_start: start, p_end: end }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Time Tracking Settings ─────────────────────────────────────────────────────
|
||||
|
||||
export type TimeTrackingSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
pay_period_start_day: number;
|
||||
pay_period_length_days: number;
|
||||
daily_overtime_threshold: number;
|
||||
weekly_overtime_threshold: number;
|
||||
overtime_multiplier: number;
|
||||
overtime_notifications: boolean;
|
||||
notification_emails: string[];
|
||||
notification_sms_numbers: string[];
|
||||
enable_daily_alerts: boolean;
|
||||
enable_weekly_alerts: boolean;
|
||||
daily_alert_threshold: number;
|
||||
weekly_alert_threshold: number;
|
||||
send_end_of_period_summary: boolean;
|
||||
brand_name: string;
|
||||
};
|
||||
|
||||
export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrackingSettings | null> {
|
||||
if (useMockData) {
|
||||
return {
|
||||
id: "settings-mock",
|
||||
brand_id: brandId,
|
||||
pay_period_start_day: 0,
|
||||
pay_period_length_days: 7,
|
||||
daily_overtime_threshold: 8,
|
||||
weekly_overtime_threshold: 40,
|
||||
overtime_multiplier: 1.5,
|
||||
overtime_notifications: true,
|
||||
notification_emails: ["admin@tuxedocorn.com"],
|
||||
notification_sms_numbers: [],
|
||||
enable_daily_alerts: true,
|
||||
enable_weekly_alerts: true,
|
||||
daily_alert_threshold: 8,
|
||||
weekly_alert_threshold: 40,
|
||||
send_end_of_period_summary: true,
|
||||
brand_name: "Tuxedo Corn",
|
||||
};
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTimeTrackingSettings(
|
||||
brandId: string,
|
||||
settings: {
|
||||
pay_period_start_day: number;
|
||||
pay_period_length_days: number;
|
||||
daily_overtime_threshold: number;
|
||||
weekly_overtime_threshold: number;
|
||||
overtime_multiplier: number;
|
||||
overtime_notifications: boolean;
|
||||
notification_emails?: string[];
|
||||
notification_sms_numbers?: string[];
|
||||
enable_daily_alerts?: boolean;
|
||||
enable_weekly_alerts?: boolean;
|
||||
daily_alert_threshold?: number;
|
||||
weekly_alert_threshold?: number;
|
||||
send_end_of_period_summary?: boolean;
|
||||
brand_name?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_time_tracking_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({
|
||||
p_brand_id: brandId,
|
||||
p_pay_period_start_day: settings.pay_period_start_day,
|
||||
p_pay_period_length_days: settings.pay_period_length_days,
|
||||
p_daily_threshold: settings.daily_overtime_threshold,
|
||||
p_weekly_threshold: settings.weekly_overtime_threshold,
|
||||
p_overtime_multiplier: settings.overtime_multiplier,
|
||||
p_overtime_notifications: settings.overtime_notifications,
|
||||
p_notification_emails: settings.notification_emails ?? null,
|
||||
p_notification_sms_numbers: settings.notification_sms_numbers ?? null,
|
||||
p_enable_daily_alerts: settings.enable_daily_alerts ?? null,
|
||||
p_enable_weekly_alerts: settings.enable_weekly_alerts ?? null,
|
||||
p_daily_alert_threshold: settings.daily_alert_threshold ?? null,
|
||||
p_weekly_alert_threshold: settings.weekly_alert_threshold ?? null,
|
||||
p_send_end_of_period_summary: settings.send_end_of_period_summary ?? null,
|
||||
p_brand_name: settings.brand_name ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Notification Log ─────────────────────────────────────────────────────────
|
||||
|
||||
export type NotificationLogEntry = {
|
||||
id: string;
|
||||
worker_id: string | null;
|
||||
worker_name: string | null;
|
||||
trigger_type: string;
|
||||
threshold_hours: number | null;
|
||||
actual_hours: number | null;
|
||||
emails_sent: string[];
|
||||
sms_numbers_sent: string[];
|
||||
email_sent: boolean;
|
||||
sms_sent: boolean;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function getTimeTrackingNotificationLog(
|
||||
brandId: string,
|
||||
limit = 100
|
||||
): Promise<NotificationLogEntry[]> {
|
||||
if (useMockData) {
|
||||
// Return empty log in mock mode
|
||||
return [];
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_notification_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_limit: limit }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
export type OvertimeCheckResult = {
|
||||
sent: boolean;
|
||||
trigger_type?: string;
|
||||
message?: string;
|
||||
notification_log_id?: string;
|
||||
};
|
||||
|
||||
export async function checkAndNotifyOvertime(
|
||||
brandId: string,
|
||||
workerId: string,
|
||||
workerName: string,
|
||||
dailyHours: number,
|
||||
weeklyHours: number
|
||||
): Promise<OvertimeCheckResult> {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/check_and_notify_overtime`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_worker_id: workerId,
|
||||
p_worker_name: workerName,
|
||||
p_daily_hours: dailyHours,
|
||||
p_weekly_hours: weeklyHours,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { sent: false, message: err };
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
sent: Boolean(data?.sent),
|
||||
trigger_type: data?.trigger_type,
|
||||
message: data?.message,
|
||||
notification_log_id: data?.notification_log_id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
type Irrigator = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
active: boolean;
|
||||
language_preference: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
unit: string;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
headgate_token?: string | null;
|
||||
last_used_at?: string | null;
|
||||
high_threshold?: number | null;
|
||||
low_threshold?: number | null;
|
||||
};
|
||||
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
headgate_id: string;
|
||||
user_id: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
measurement: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
logged_at: string;
|
||||
headgate_unit?: string;
|
||||
};
|
||||
|
||||
// ── Headgate Admin ──────────────────────────────────────────
|
||||
|
||||
export async function createWaterHeadgate(brandId: string, name: string, unit: string = "CFS"): Promise<{ success: boolean; headgate?: Headgate; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_headgate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_name: name, p_unit: unit }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.headgate) {
|
||||
return { success: false, error: data?.message ?? "Failed to create headgate" };
|
||||
}
|
||||
return { success: true, headgate: data.headgate };
|
||||
}
|
||||
|
||||
export async function updateWaterHeadgate(
|
||||
headgateId: string,
|
||||
name: string,
|
||||
active: boolean,
|
||||
unit?: string,
|
||||
highThreshold?: number | null,
|
||||
lowThreshold?: number | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_water_headgate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_headgate_id: headgateId,
|
||||
p_name: name,
|
||||
p_active: active,
|
||||
p_unit: unit ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_high_threshold: highThreshold ?? null,
|
||||
p_low_threshold: lowThreshold ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to update headgate" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Irrigator Admin ─────────────────────────────────────────
|
||||
|
||||
export async function getWaterIrrigators(brandId: string): Promise<Irrigator[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_users`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.users ?? [];
|
||||
}
|
||||
|
||||
export async function createWaterIrrigator(
|
||||
brandId: string,
|
||||
name: string,
|
||||
lang: string = "en"
|
||||
): Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }> {
|
||||
return createWaterUser(brandId, name, "irrigator", lang) as Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }>;
|
||||
}
|
||||
|
||||
export async function createWaterUser(
|
||||
brandId: string,
|
||||
name: string,
|
||||
role: "irrigator" | "water_admin",
|
||||
lang: string = "en"
|
||||
): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_name: name, p_role: role, p_lang: lang }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to create user" };
|
||||
}
|
||||
return { success: true, user: data.user, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function updateWaterIrrigator(
|
||||
irrigatorId: string,
|
||||
name: string,
|
||||
active: boolean,
|
||||
lang: string,
|
||||
role: "irrigator" | "water_admin"
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_water_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: irrigatorId,
|
||||
p_name: name,
|
||||
p_active: active,
|
||||
p_lang: lang,
|
||||
p_role: role,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to update user" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function resetWaterIrrigatorPin(
|
||||
irrigatorId: string
|
||||
): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/reset_water_user_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: irrigatorId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to reset PIN" };
|
||||
}
|
||||
return { success: true, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function deleteWaterUser(userId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_water_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: userId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete user" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWaterHeadgate(headgateId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_water_headgate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_headgate_id: headgateId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete headgate" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Entries ────────────────────────────────────────────────
|
||||
|
||||
export async function getWaterEntries(brandId: string, limit = 50): Promise<WaterEntry[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_entries`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_limit: limit }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.entries ?? [];
|
||||
}
|
||||
|
||||
export async function getWaterHeadgatesAdmin(brandId: string): Promise<Headgate[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_headgates_admin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.headgates ?? [];
|
||||
}
|
||||
|
||||
export async function regenerateHeadgateToken(headgateId: string): Promise<{ success: boolean; token?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/regenerate_headgate_token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to regenerate token" };
|
||||
}
|
||||
return { success: true, token: data.token };
|
||||
}
|
||||
|
||||
// ── Entry edit/delete ─────────────────────────────────────
|
||||
|
||||
export async function updateWaterEntry(
|
||||
entryId: string,
|
||||
measurement: number,
|
||||
notes: string | null,
|
||||
unit?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_water_entry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_measurement: measurement,
|
||||
p_notes: notes,
|
||||
p_unit: unit ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to update entry" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWaterEntry(
|
||||
entryId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_water_entry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete entry" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getWaterEntryById(entryId: string): Promise<WaterEntry | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_entry_by_id`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_entry_id: entryId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data?.entry ?? null;
|
||||
}
|
||||
|
||||
// ── Display summary ───────────────────────────────────
|
||||
|
||||
export type WaterDisplayEntry = {
|
||||
logged_at: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
user_role: string;
|
||||
measurement: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
};
|
||||
|
||||
export type WaterDisplayHeadgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
latest_entry: { measurement: number; user_name: string; logged_at: string } | null;
|
||||
last_logged_at: string | null;
|
||||
minutes_ago: number | null;
|
||||
};
|
||||
|
||||
export type WaterDisplaySummary = {
|
||||
headgates: WaterDisplayHeadgate[];
|
||||
today_count: number;
|
||||
today_total: number;
|
||||
recent_entries: WaterDisplayEntry[];
|
||||
};
|
||||
|
||||
export async function getWaterDisplaySummary(brandId: string): Promise<WaterDisplaySummary | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_display_summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as WaterDisplaySummary;
|
||||
}
|
||||
|
||||
export type AlertLogEntry = {
|
||||
id: string;
|
||||
alert_type: "high" | "low";
|
||||
threshold_value: number;
|
||||
reading_value: number;
|
||||
message_sent: string | null;
|
||||
sent_at: string;
|
||||
created_at: string;
|
||||
headgate_name: string;
|
||||
formatted_time: string;
|
||||
};
|
||||
|
||||
export async function getWaterAlertLog(brandId: string, limit = 50): Promise<AlertLogEntry[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_alert_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_limit: limit }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.alerts ?? [];
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
type VerifyPinResult = {
|
||||
success: true;
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
session_id: string;
|
||||
lang: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type SubmitEntryResult = {
|
||||
success: true;
|
||||
entry_id: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type HeadgatesResult = {
|
||||
headgates: Headgate[];
|
||||
};
|
||||
|
||||
export async function getWaterHeadgates(brandId: string, activeOnly = false): Promise<Headgate[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_headgates`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_active_only: activeOnly }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.headgates ?? [];
|
||||
}
|
||||
|
||||
export async function verifyWaterPin(brandId: string, pin: string): Promise<VerifyPinResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/verify_water_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_pin: pin }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Invalid PIN" };
|
||||
}
|
||||
|
||||
// Get user's language preference via SECURITY DEFINER RPC (avoids direct table access)
|
||||
const userResponse = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_user_by_id`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: data.user_id }),
|
||||
}
|
||||
);
|
||||
const userData = await userResponse.json();
|
||||
const lang = userData?.language_preference ?? "en";
|
||||
|
||||
// Use the session already created by verify_water_pin RPC
|
||||
const sessionId = data.session_id;
|
||||
|
||||
if (!sessionId) {
|
||||
return { success: false, error: "Failed to create session" };
|
||||
}
|
||||
|
||||
// Set HTTP-only session cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_session", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 4 * 60 * 60, // 4 hours
|
||||
path: "/",
|
||||
});
|
||||
cookieStore.set("wl_lang", lang, {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user_id: data.user_id,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
session_id: sessionId,
|
||||
lang,
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitWaterEntry(
|
||||
headgateId: string,
|
||||
measurement: number,
|
||||
unit: string,
|
||||
notes: string,
|
||||
photoUrl?: string,
|
||||
latitude?: number,
|
||||
longitude?: number,
|
||||
headgateLocked?: boolean
|
||||
): Promise<SubmitEntryResult> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
|
||||
if (!sessionId) {
|
||||
return { success: false, error: "Not logged in" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/submit_water_entry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_session_id: sessionId,
|
||||
p_headgate_id: headgateId,
|
||||
p_measurement: measurement,
|
||||
p_unit: unit,
|
||||
p_notes: notes,
|
||||
p_submitted_via: "field",
|
||||
p_photo_url: photoUrl ?? null,
|
||||
p_latitude: latitude ?? null,
|
||||
p_longitude: longitude ?? null,
|
||||
p_headgate_locked: headgateLocked ?? false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to submit entry" };
|
||||
}
|
||||
|
||||
const entryId = data.entry_id as string;
|
||||
|
||||
// ── Alert check (fire-and-forget, non-blocking) ─────────────────
|
||||
try {
|
||||
const alertRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/trigger_water_alert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_alert_type: "high",
|
||||
p_threshold_value: measurement,
|
||||
p_reading_value: measurement,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const highData = await alertRes.json();
|
||||
// If not triggered as high, try low
|
||||
if (!highData?.triggered) {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/trigger_water_alert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_alert_type: "low",
|
||||
p_threshold_value: measurement,
|
||||
p_reading_value: measurement,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Alert failures should not affect entry submission success
|
||||
}
|
||||
|
||||
return { success: true, entry_id: entryId };
|
||||
}
|
||||
|
||||
export async function logoutWater(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete("wl_session");
|
||||
}
|
||||
|
||||
export async function logoutWaterAdmin(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete("wl_admin_session");
|
||||
}
|
||||
|
||||
export async function getWaterSession(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get("wl_session")?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setWaterLang(lang: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_lang", lang, {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWaterAdminSession(): Promise<{ user_id: string; name: string; role: string } | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Use get_water_admin_session RPC (SECURITY DEFINER) to avoid direct water_sessions + water_users JOIN
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_admin_session`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_session_id: sessionId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type WaterAdminSettings = {
|
||||
enabled: boolean;
|
||||
session_duration_hours: number;
|
||||
can_edit_entries: boolean;
|
||||
can_delete_entries: boolean;
|
||||
can_export_csv: boolean;
|
||||
alert_phone?: string | null;
|
||||
alerts_enabled?: boolean;
|
||||
};
|
||||
|
||||
export async function getWaterAdminSettings(brandId: string): Promise<WaterAdminSettings | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as WaterAdminSettings;
|
||||
}
|
||||
|
||||
export async function saveWaterAdminSettings(
|
||||
brandId: string,
|
||||
settings: Partial<WaterAdminSettings & { pin?: string }>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
let pinHash: string | null = null;
|
||||
if (settings.pin) {
|
||||
// Hash the PIN server-side using PostgreSQL
|
||||
const hashRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/hash_water_admin_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_pin: settings.pin }),
|
||||
}
|
||||
);
|
||||
const hashData = await hashRes.json();
|
||||
if (!hashData?.hash) return { success: false, error: "Failed to hash PIN" };
|
||||
pinHash = hashData.hash;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/save_water_admin_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_enabled: settings.enabled ?? null,
|
||||
p_pin_hash: pinHash,
|
||||
p_session_duration_hours: settings.session_duration_hours ?? null,
|
||||
p_can_edit_entries: settings.can_edit_entries ?? null,
|
||||
p_can_delete_entries: settings.can_delete_entries ?? null,
|
||||
p_can_export_csv: settings.can_export_csv ?? null,
|
||||
p_alert_phone: settings.alert_phone ?? null,
|
||||
p_alerts_enabled: settings.alerts_enabled ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to save settings" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function verifyWaterAdminPin(
|
||||
brandId: string,
|
||||
pin: string
|
||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
const settings = await settingsRes.json();
|
||||
if (!settingsRes.ok || !settings?.enabled) {
|
||||
return { success: false, error: "Admin portal not enabled" };
|
||||
}
|
||||
|
||||
// Verify PIN against stored hash
|
||||
const verifyRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/verify_water_admin_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_pin: pin }),
|
||||
}
|
||||
);
|
||||
const verifyData = await verifyRes.json();
|
||||
if (!verifyRes.ok || !verifyData?.success) {
|
||||
return { success: false, error: "Invalid PIN" };
|
||||
}
|
||||
|
||||
return { success: true, session_id: verifyData.session_id };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type WholesaleLoginResult =
|
||||
| { success: true; token: string; userId: string; customerId: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function wholesaleLoginAction(formData: FormData): Promise<WholesaleLoginResult> {
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const request = new NextRequest("http://localhost/wholesale/login", {
|
||||
headers: new Headers(),
|
||||
});
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
const { createServerClient } = await import("@supabase/ssr");
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error || !data.user) {
|
||||
return { success: false, error: error?.message ?? "Invalid credentials" };
|
||||
}
|
||||
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const token = sessionData?.session?.access_token;
|
||||
|
||||
if (!token) {
|
||||
return { success: false, error: "No session returned from auth" };
|
||||
}
|
||||
|
||||
// Find the wholesale customer record for this user
|
||||
const customerRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseAnonKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: "placeholder", // will use any-brand lookup below
|
||||
p_user_id: data.user.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// If no brand_id known, try all brands — just use first active one found
|
||||
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
|
||||
response.cookies.set("wholesale_session", JSON.stringify({
|
||||
user_id: data.user.id,
|
||||
access_token: token,
|
||||
}), {
|
||||
path: "/",
|
||||
maxAge: 3600 * 24 * 7,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
// Also set the standard auth token for RPC calls
|
||||
response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, {
|
||||
path: "/",
|
||||
maxAge: 3600 * 24 * 7,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
userId: data.user.id,
|
||||
customerId: "pending", // resolved by portal page on load
|
||||
};
|
||||
}
|
||||
|
||||
export async function wholesaleLogoutAction() {
|
||||
const cookieStore = await cookies();
|
||||
const request = new NextRequest("http://localhost/wholesale/portal", {
|
||||
headers: new Headers(),
|
||||
});
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
response.cookies.delete("wholesale_session");
|
||||
|
||||
const { createServerClient } = await import("@supabase/ssr");
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await supabase.auth.signOut();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function registerWholesaleCustomer(params: {
|
||||
brandId: string;
|
||||
companyName: string;
|
||||
contactName?: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_company_name: params.companyName,
|
||||
p_contact_name: params.contactName ?? null,
|
||||
p_email: params.email,
|
||||
p_phone: params.phone ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
// Supabase error format: { "message": "...", "code": "...", ... }
|
||||
// Our RPC error format: { "success": false, "error": "..." }
|
||||
return { success: false, error: err.message ?? err.error ?? "Registration failed." };
|
||||
}
|
||||
const data = await response.json();
|
||||
// Normalize: RPC may return an array (single row) or a plain object
|
||||
const result = Array.isArray(data) ? data[0] : data;
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error ?? "Registration failed." };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
requiresApproval: result.requires_approval ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPendingWholesaleRegistrations(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
if (!adminUser.can_manage_orders) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function approveWholesaleRegistration(
|
||||
registrationId: string,
|
||||
brandId: string,
|
||||
action: "approve" | "reject"
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
if (adminUser.role !== "platform_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized to operate on this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_registration_id: registrationId,
|
||||
p_brand_id: brandId,
|
||||
p_action: action,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to process registration." };
|
||||
const data = await response.json();
|
||||
// Normalize array response (RETURNING single row) to plain object
|
||||
const result = Array.isArray(data) ? data[0] : data;
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error ?? "Failed to process registration." };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getWholesaleCustomerByUser(
|
||||
brandId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
company_name: string;
|
||||
contact_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
account_status: string;
|
||||
role: string;
|
||||
brand_id: string;
|
||||
} | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_user_id: userId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data ?? null;
|
||||
}
|
||||
|
||||
// Fetch a wholesale customer directly by their customer ID (used for admin preview mode)
|
||||
export async function getWholesaleCustomer(
|
||||
customerId: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
company_name: string;
|
||||
contact_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
account_status: string;
|
||||
role: string;
|
||||
brand_id: string;
|
||||
} | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getWholesaleProducts(brandId: string) {
|
||||
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function submitWholesaleOrder(params: {
|
||||
brandId: string;
|
||||
customerId: string;
|
||||
anticipatedPickupDate?: string;
|
||||
items: Array<{ product_id: string; quantity: number; unit_price: number }>;
|
||||
notes?: string;
|
||||
checkoutSessionId?: string; // pass explicitly for caller-generated UUID
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
orderId?: string;
|
||||
invoiceNumber?: string;
|
||||
status?: string;
|
||||
depositRequired?: number;
|
||||
creditLimit?: number;
|
||||
outstandingBalance?: number;
|
||||
orderTotal?: number;
|
||||
idempotent?: boolean;
|
||||
}> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Generate UUID at the start of checkout — before RPC call for true idempotency
|
||||
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
|
||||
|
||||
const itemsJson = params.items.map(i => ({
|
||||
product_id: i.product_id,
|
||||
quantity: i.quantity,
|
||||
unit_price: i.unit_price,
|
||||
}));
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_wholesale_order`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_customer_id: params.customerId,
|
||||
p_anticipated_pickup_date: params.anticipatedPickupDate ?? null,
|
||||
p_items: itemsJson,
|
||||
p_notes: params.notes ?? null,
|
||||
p_checkout_session_id: checkoutSessionId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to create order." };
|
||||
const data = await response.json();
|
||||
// Normalize array response (RETURNING single row) to plain object
|
||||
const result = Array.isArray(data) ? data[0] : data;
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error ?? "Failed to create order.",
|
||||
creditLimit: result.credit_limit,
|
||||
outstandingBalance: result.outstanding_balance,
|
||||
orderTotal: result.order_total,
|
||||
};
|
||||
}
|
||||
|
||||
// Fire webhook — fire-and-forget, don't block the response
|
||||
enqueueWholesaleWebhookForOrderCreated(
|
||||
result.order_id,
|
||||
params.brandId,
|
||||
params.customerId,
|
||||
result.invoice_number,
|
||||
Number(result.deposit_required) || 0
|
||||
).catch(() => {});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderId: result.order_id,
|
||||
invoiceNumber: result.invoice_number,
|
||||
status: result.status,
|
||||
depositRequired: result.deposit_required,
|
||||
idempotent: result.idempotent ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!;
|
||||
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_event_type: "order_created",
|
||||
p_order_id: orderId,
|
||||
p_brand_id: brandId,
|
||||
p_payload: { order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal },
|
||||
}),
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export type WholesaleProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unit_type: string;
|
||||
availability: string;
|
||||
qty_available: number;
|
||||
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||
hp_sku: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WholesaleCustomerOrder = {
|
||||
id: string;
|
||||
status: string;
|
||||
fulfillment_status: string;
|
||||
payment_status: string;
|
||||
anticipated_pickup_date: string | null;
|
||||
subtotal: number;
|
||||
deposit_required: number;
|
||||
deposit_paid: number;
|
||||
balance_due: number;
|
||||
invoice_number: string | null;
|
||||
invoice_token: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
line_total: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type WholesalePricingOverride = {
|
||||
product_id: string;
|
||||
custom_unit_price: number;
|
||||
product_name: string;
|
||||
};
|
||||
|
||||
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_customer_id: customerId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function upsertWholesaleCustomerPricing(params: {
|
||||
customerId: string;
|
||||
productId: string;
|
||||
customUnitPrice: number;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_customer_id: params.customerId,
|
||||
p_product_id: params.productId,
|
||||
p_custom_unit_price: params.customUnitPrice,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to save pricing override" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWholesaleCustomerPricing(params: {
|
||||
customerId: string;
|
||||
productId: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_customer_id: params.customerId,
|
||||
p_product_id: params.productId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete pricing override" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_customer_id: customerId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import CommandCenterDashboard from "@/components/admin/CommandCenterDashboard";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function CommandCenterPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (adminUser.role !== "platform_admin") return <AdminAccessDenied message="Platform admin access required." />;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black px-4 py-8">
|
||||
<div className="mx-auto max-w-[1600px]">
|
||||
<CommandCenterDashboard />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrandSettings } from "@/actions/brand-settings";
|
||||
import AbandonedCartDashboard from "@/components/admin/AbandonedCartDashboard";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
|
||||
export default async function AbandonedCartsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
const settingsResult = await getBrandSettings(brandId);
|
||||
const brandName = settingsResult?.success ? (settingsResult.settings?.brand_name ?? "Farm") : "Farm";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/communications" className="hover:text-stone-600 transition-colors">Communications</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Abandoned Cart Recovery</span>
|
||||
</nav>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-950">Abandoned Cart Recovery</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">{brandName} — 3-email sequence (1h, 24h, 48h)</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-stone-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-600" />
|
||||
Active
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-600" />
|
||||
Recovered
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-stone-400" />
|
||||
Expired
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AbandonedCartDashboard brandId={brandId} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const analytics = await getCampaignAnalytics(effectiveBrandId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={[]}
|
||||
activeTab="analytics"
|
||||
brandId={effectiveBrandId}
|
||||
initialAnalytics={analytics}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns, getCampaignById } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function CampaignEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const { id } = await params;
|
||||
const isNew = id === "new";
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult] = await Promise.all([
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
]);
|
||||
|
||||
const campaign = isNew ? undefined : id ? await getCampaignById(id) : undefined;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="campaigns"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
editCampaign={campaign}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getHarvestReachCampaigns } from "@/actions/harvest-reach/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function ComposePage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
|
||||
getHarvestReachCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
getHarvestReachSegments(effectiveBrandId),
|
||||
]);
|
||||
|
||||
const campaigns = campaignsResult.success ? campaignsResult.campaigns : [];
|
||||
const templates = templatesResult.success ? templatesResult.templates : [];
|
||||
const segments = segmentsResult.success ? segmentsResult.segments : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={campaigns}
|
||||
templates={templates}
|
||||
activeTab="compose"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getContacts } from "@/actions/communications/contacts";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, contactsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getContacts({ brandId, limit: 50 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, contacts, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="contacts"
|
||||
brandId={brandId}
|
||||
initialContacts={contactsResult.success ? contactsResult.contacts : []}
|
||||
initialContactTotal={contactsResult.success ? contactsResult.total : 0}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getMessageLogs } from "@/actions/communications/send";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function LogsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, logsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getMessageLogs({ brandId, limit: 200 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="logs"
|
||||
brandId={brandId}
|
||||
initialLogs={logsResult.success ? logsResult.logs : []}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function CommunicationsRootPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, segmentsResult, analyticsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
getHarvestReachSegments(effectiveBrandId),
|
||||
getCampaignAnalytics(effectiveBrandId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Harvest Reach</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="campaigns"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
initialAnalytics={analyticsResult}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function SegmentsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const segmentsResult = await getHarvestReachSegments(effectiveBrandId);
|
||||
const segments = segmentsResult.success ? segmentsResult.segments : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={[]}
|
||||
activeTab="segments"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getCommunicationSettings } from "@/actions/communications/settings";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, settingsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getCommunicationSettings(brandId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="settings"
|
||||
brandId={brandId}
|
||||
initialSettings={settingsResult}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationTemplates, getTemplateById } from "@/actions/communications/templates";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function TemplateEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const { id } = await params;
|
||||
const isNew = id === "new";
|
||||
|
||||
const templatesResult = await getCommunicationTemplates();
|
||||
const template = isNew ? undefined : id ? await getTemplateById(id) : undefined;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="templates"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
editTemplate={template}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const [campaignsResult, templatesResult] = await Promise.all([
|
||||
getCommunicationCampaigns(adminUser.brand_id ?? undefined),
|
||||
getCommunicationTemplates(adminUser.brand_id ?? undefined),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="templates"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrandSettings } from "@/actions/brand-settings";
|
||||
import WelcomeSequenceDashboard from "@/components/admin/WelcomeSequenceDashboard";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default async function WelcomeSequencePage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
const settingsResult = await getBrandSettings(brandId);
|
||||
const brandName = settingsResult?.success ? (settingsResult.settings?.brand_name ?? "Farm") : "Farm";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/communications" className="hover:text-stone-600 transition-colors">Communications</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Welcome Sequence</span>
|
||||
</nav>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-950">Welcome Email Sequence</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">{brandName} — 4-email onboarding series</p>
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">
|
||||
Auto-enrolls new subscribers (email opt-in)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WelcomeSequenceDashboard brandId={brandId} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export default async function DebugAuthPage() {
|
||||
const cookieStore = await cookies();
|
||||
const allCookies = cookieStore.getAll();
|
||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
||||
|
||||
let adminUsersStatus = "not_tried";
|
||||
let adminUsersResult: string | null = null;
|
||||
|
||||
if (rcAuthUid) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (supabaseUrl && serviceKey) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
||||
);
|
||||
adminUsersStatus = String(res.status);
|
||||
const data = await res.json().catch(() => null);
|
||||
adminUsersResult = JSON.stringify(data);
|
||||
} catch (e) {
|
||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} else {
|
||||
adminUsersStatus = "missing_env_vars";
|
||||
}
|
||||
} else {
|
||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
||||
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
|
||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
|
||||
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
||||
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
||||
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
|
||||
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
|
||||
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-6 relative">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-red-500/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md mx-auto relative">
|
||||
<div className="glass-card p-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
|
||||
<svg className="w-8 h-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-white tracking-tight">Admin Error</h1>
|
||||
<p className="mt-3 text-zinc-400 text-sm">
|
||||
{error.message || "An unexpected error occurred in the admin panel."}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 px-5 py-2.5 text-sm font-medium text-white transition-all backdrop-blur-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
Back to Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { analyzeImport, executeImport, type ImportAnalysis, type ImportEntityType } from "@/actions/ai-import";
|
||||
|
||||
type Step = "upload" | "analysis" | "preview" | "import";
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
products: "Products",
|
||||
orders: "Orders",
|
||||
contacts: "Contacts",
|
||||
stops: "Stops",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const ALL_FIELDS = ["ignore", "product_name", "price", "description", "product_type", "active", "image_url",
|
||||
"customer_name", "customer_email", "customer_phone", "stop_id", "quantity", "fulfillment", "product_id",
|
||||
"first_name", "last_name", "full_name", "tags", "email_opt_in", "sms_opt_in", "external_id",
|
||||
"city", "state", "location", "date", "time", "address", "zip", "notes"];
|
||||
|
||||
type Brand = { id: string; name: string };
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
brands?: Brand[];
|
||||
isPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
export default function ImportCenterClient({ brandId: initialBrandId, brandName: initialBrandName, brands = [], isPlatformAdmin = false }: Props) {
|
||||
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
|
||||
const [activeBrandName, setActiveBrandName] = useState(initialBrandName);
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [base64Data, setBase64Data] = useState("");
|
||||
const [analysis, setAnalysis] = useState<ImportAnalysis | null>(null);
|
||||
const [editedMappings, setEditedMappings] = useState<Record<string, string>>({});
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<Step>("upload");
|
||||
const [analysisStep, setAnalysisStep] = useState(0);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{ success: boolean; created?: number; updated?: number; errors: { row: number; message: string }[] } | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const analysisTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleBrandChange(newBrandId: string) {
|
||||
setActiveBrandId(newBrandId);
|
||||
const found = brands.find((b) => b.id === newBrandId);
|
||||
if (found) setActiveBrandName(found.name);
|
||||
}
|
||||
|
||||
function handleFile(file: File) {
|
||||
setFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
setBase64Data(result.replace(/^data:[^;]+;base64,/, ""));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (!base64Data || !fileName) return;
|
||||
setAnalysisError(null);
|
||||
setStep("analysis");
|
||||
setAnalysisStep(0);
|
||||
|
||||
const messages = [
|
||||
"Reading your file...",
|
||||
"AI is mapping columns...",
|
||||
"Cleaning and normalizing data...",
|
||||
"Finalizing preview...",
|
||||
];
|
||||
let idx = 0;
|
||||
analysisTimerRef.current = setInterval(() => {
|
||||
idx = (idx + 1) % messages.length;
|
||||
setAnalysisStep(idx);
|
||||
}, 1500);
|
||||
|
||||
const result = await analyzeImport(base64Data, fileName, activeBrandId);
|
||||
if (analysisTimerRef.current) clearInterval(analysisTimerRef.current);
|
||||
|
||||
if (result.success) {
|
||||
setAnalysis(result.analysis);
|
||||
setEditedMappings(result.analysis.columnMappings);
|
||||
setStep("preview");
|
||||
} else {
|
||||
setAnalysisError(result.error ?? "Analysis failed");
|
||||
setStep("upload");
|
||||
}
|
||||
}
|
||||
|
||||
function updateMapping(header: string, field: string) {
|
||||
setEditedMappings((prev) => ({ ...prev, [header]: field }));
|
||||
}
|
||||
|
||||
function overrideType(type: ImportEntityType) {
|
||||
if (!analysis) return;
|
||||
setAnalysis({ ...analysis, detectedType: type, confidence: 1.0 });
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!analysis) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
const mappedRows = analysis.rawRows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
analysis.headers.forEach((header, i) => {
|
||||
const field = editedMappings[header];
|
||||
if (field && field !== "ignore") {
|
||||
obj[field] = row[i]?.trim() ?? "";
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const result = await executeImport(activeBrandId, analysis.detectedType, mappedRows);
|
||||
setImporting(false);
|
||||
|
||||
if (result.success) {
|
||||
setImportResult(result);
|
||||
setStep("import");
|
||||
} else {
|
||||
setImportError(result.error ?? "Import failed");
|
||||
}
|
||||
}
|
||||
|
||||
const stepIndex = ["upload", "analysis", "preview", "import"].indexOf(step);
|
||||
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-zinc-800 border border-zinc-700">
|
||||
<svg className="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-zinc-100">Import Center</h1>
|
||||
<p className="mt-1 text-zinc-500 text-sm">AI-powered data import for products, orders, contacts, and stops.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="mb-8 flex items-center gap-2">
|
||||
{(["upload", "analysis", "preview", "import"] as Step[]).map((s, i) => {
|
||||
const labels = ["Upload", "AI Analysis", "Preview & Edit", "Import"];
|
||||
const isActive = s === step;
|
||||
const isPast = stepIndex > i;
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
||||
isActive ? "bg-violet-600 text-white" : isPast ? "bg-green-600 text-white" : "bg-zinc-800 text-zinc-500"
|
||||
}`}>
|
||||
{isPast ? "✓" : i + 1}
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${isActive ? "text-zinc-100" : "text-zinc-500"}`}>{labels[i]}</span>
|
||||
{i < 3 && <div className="h-px w-8 bg-zinc-800" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Brand selector */}
|
||||
{isPlatformAdmin && (
|
||||
<div className="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-4">
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Importing to brand</label>
|
||||
<select
|
||||
value={activeBrandId}
|
||||
onChange={(e) => handleBrandChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-2.5 text-sm text-zinc-100 outline-none focus:border-violet-500"
|
||||
>
|
||||
{brands.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Upload ── */}
|
||||
{step === "upload" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-zinc-700 bg-zinc-900 p-12 cursor-pointer hover:border-violet-500 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="h-10 w-10 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-medium text-zinc-300">Drag & drop your file here</p>
|
||||
<p className="mt-1 text-sm text-zinc-500">or click to browse</p>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">CSV, XLSX, XLS, TXT · max 5,000 rows · 10MB</p>
|
||||
<input ref={inputRef} type="file" accept=".csv,.xlsx,.xls,.txt" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
|
||||
</div>
|
||||
|
||||
{fileName && (
|
||||
<div className="rounded-xl bg-green-900/30 border border-green-800 p-4 flex items-center gap-3">
|
||||
<svg className="h-5 w-5 text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-300 truncate">{fileName}</p>
|
||||
<p className="text-xs text-green-500">Ready to analyze</p>
|
||||
</div>
|
||||
<button onClick={() => { setFileName(""); setBase64Data(""); }} className="text-sm text-green-400 font-medium hover:text-green-300">Change</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisError && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-800 p-4 text-sm text-red-300">{analysisError}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!base64Data}
|
||||
className="w-full rounded-xl bg-violet-600 px-6 py-3.5 text-base font-bold text-white hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Analyze with AI
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl bg-violet-900/20 border border-violet-800 p-4">
|
||||
<p className="text-sm font-medium text-violet-300 mb-1">AI-assisted import</p>
|
||||
<p className="text-xs text-violet-500">The Import Center auto-detects your data type, maps columns intelligently, and normalizes messy data (phone numbers, dates, prices). Supports products, orders, contacts, and stops.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Analysis Loading ── */}
|
||||
{step === "analysis" && (
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-16 text-center">
|
||||
<div className="mx-auto mb-6 h-12 w-12 rounded-full border-4 border-violet-600 border-t-transparent animate-spin" />
|
||||
<p className="text-xl font-semibold text-zinc-300 mb-2">{analysisLabels[analysisStep]}</p>
|
||||
<p className="text-sm text-zinc-500">This usually takes 5–10 seconds</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Preview & Edit ── */}
|
||||
{step === "preview" && analysis && (
|
||||
<div className="space-y-6">
|
||||
{/* Detected type banner */}
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider px-2 py-1 rounded-full ${
|
||||
analysis.confidence > 0.8 ? "bg-green-900/60 text-green-300 border border-green-700" : analysis.confidence > 0.5 ? "bg-amber-900/60 text-amber-300 border border-amber-700" : "bg-red-900/60 text-red-300 border border-red-700"
|
||||
}`}>
|
||||
{ENTITY_LABELS[analysis.detectedType] ?? "Unknown"}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{Math.round(analysis.confidence * 100)}% confidence
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{analysis.rowCount.toLocaleString()} rows detected
|
||||
{analysis.autoFixApplied.length > 0 && (
|
||||
<span className="ml-2 text-green-400">· {analysis.autoFixApplied.join(", ")}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{analysis.confidence < 0.8 && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-zinc-500 mb-1">Is this wrong?</p>
|
||||
<div className="flex gap-1">
|
||||
{(["products", "orders", "stops", "contacts"] as ImportEntityType[]).map((t) => (
|
||||
<button key={t} onClick={() => overrideType(t)} className={`text-xs px-2 py-1 rounded-lg border transition-colors ${
|
||||
t === analysis.detectedType ? "bg-violet-600 text-white border-violet-600" : "border-zinc-600 text-zinc-400 hover:bg-zinc-800"
|
||||
}`}>{ENTITY_LABELS[t]}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{analysis.warnings.length > 0 && (
|
||||
<div className="rounded-xl bg-amber-900/30 border border-amber-800 p-4">
|
||||
<p className="text-sm font-medium text-amber-300 mb-2">Issues detected</p>
|
||||
<ul className="text-xs text-amber-500 space-y-1">
|
||||
{analysis.warnings.slice(0, 5).map((w, i) => <li key={i}>• {w}</li>)}
|
||||
{analysis.warnings.length > 5 && <li>...and {analysis.warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column mapping table */}
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Column Mappings</h3>
|
||||
<span className="text-xs text-zinc-600">AI auto-detected — click to change</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-zinc-800/50">
|
||||
<th className="px-5 py-2.5 text-left font-medium text-zinc-400">Your Column</th>
|
||||
<th className="px-5 py-2.5 text-left font-medium text-zinc-400">Maps To</th>
|
||||
<th className="px-5 py-2.5 text-left font-medium text-zinc-400">Sample Values</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analysis.headers.map((header) => {
|
||||
const mappedField = editedMappings[header];
|
||||
const sampleIdx = analysis.headers.indexOf(header);
|
||||
const samples = analysis.rawRows.slice(0, 3).map((r) => r[sampleIdx] ?? "").filter(Boolean);
|
||||
return (
|
||||
<tr key={header} className="border-t border-zinc-800 hover:bg-zinc-800/50">
|
||||
<td className="px-5 py-2.5 font-medium text-zinc-200 whitespace-nowrap">{header}</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<select
|
||||
value={mappedField ?? ""}
|
||||
onChange={(e) => updateMapping(header, e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-800 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-violet-500"
|
||||
>
|
||||
<option value="">— ignore —</option>
|
||||
{ALL_FIELDS.map((f) => <option key={f} value={f}>{f || "(same)"}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-zinc-500 text-xs max-w-xs truncate">{samples.join(", ") || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data preview */}
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-zinc-800">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Data Preview <span className="text-zinc-600 font-normal">(first 5 rows)</span></h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-zinc-800/50">
|
||||
{analysis.headers.map((h) => (
|
||||
<th key={h} className="px-4 py-2 text-left font-medium text-zinc-400 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analysis.rawRows.slice(0, 5).map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-zinc-800">
|
||||
{analysis.headers.map((h, ci) => {
|
||||
const field = editedMappings[h];
|
||||
const val = row[ci] ?? "";
|
||||
const isMapped = field && field !== "ignore";
|
||||
return (
|
||||
<td key={ci} className={`px-4 py-2 whitespace-nowrap ${isMapped ? "text-zinc-300" : "text-zinc-600 italic"}`}>
|
||||
{String(val).slice(0, 60)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setStep("upload"); setAnalysis(null); }} className="rounded-xl border border-zinc-600 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800">
|
||||
← Upload Different File
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!analysis.detectedType || analysis.detectedType === "unknown" || importing}
|
||||
className="flex-1 rounded-xl bg-violet-600 px-6 py-3 text-base font-bold text-white hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${analysis.rowCount.toLocaleString()} ${analysis.detectedType !== "unknown" ? ENTITY_LABELS[analysis.detectedType] : "rows"}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Import Result ── */}
|
||||
{step === "import" && (
|
||||
<div className="space-y-4">
|
||||
{importResult?.success ? (
|
||||
<div className="rounded-2xl bg-zinc-900 border border-green-800 p-8 text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-green-900/50 border border-green-700 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-zinc-100 mb-2">Import Complete</h2>
|
||||
{importResult.created !== undefined && <p className="text-zinc-400">{importResult.created} created{importResult.updated ? `, ${importResult.updated} updated` : ""}</p>}
|
||||
{importResult.errors.length > 0 && (
|
||||
<p className="mt-2 text-sm text-amber-400">{importResult.errors.length} rows had errors</p>
|
||||
)}
|
||||
<div className="mt-6 flex gap-3 justify-center">
|
||||
<button onClick={() => { setStep("upload"); setAnalysis(null); setFileName(""); setBase64Data(""); setImportResult(null); }} className="rounded-xl border border-zinc-600 px-6 py-2.5 text-sm font-medium text-zinc-300 hover:bg-zinc-800">
|
||||
Import Another
|
||||
</button>
|
||||
<a href={analysis?.detectedType === "products" ? "/admin/products" : analysis?.detectedType === "orders" ? "/admin/orders" : "/admin"} className="rounded-xl bg-violet-600 px-6 py-2.5 text-sm font-bold text-white hover:bg-violet-700">
|
||||
View {analysis ? ENTITY_LABELS[analysis.detectedType] : ""}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-zinc-900 border border-red-800 p-8 text-center">
|
||||
<h2 className="text-xl font-bold text-zinc-100 mb-2">Import Failed</h2>
|
||||
<p className="text-zinc-400">{importError ?? "An error occurred during import."}</p>
|
||||
<button onClick={() => setStep("preview")} className="mt-4 rounded-xl border border-zinc-600 px-6 py-2.5 text-sm font-medium text-zinc-300 hover:bg-zinc-800">
|
||||
← Back to Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import ImportCenterClient from "./ImportCenterClient";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
|
||||
export default async function ImportCenterPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
const brandId = adminUser?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const brandName = "Brand";
|
||||
|
||||
let brands: { id: string; name: string }[] = [];
|
||||
let isPlatformAdmin = false;
|
||||
|
||||
if (adminUser?.role === "platform_admin") {
|
||||
isPlatformAdmin = true;
|
||||
const result = await getBrands();
|
||||
if (!result.error && result.brands) {
|
||||
brands = result.brands;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Import</span>
|
||||
</nav>
|
||||
<ImportCenterClient
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) {
|
||||
return (
|
||||
<>
|
||||
<AdminSidebar userRole={null} />
|
||||
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
|
||||
<AdminAccessDenied message="Your account does not have admin access." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (adminUser.must_change_password) {
|
||||
redirect("/change-password");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminSidebar userRole={adminUser.role} />
|
||||
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import LoadingSkeleton, { SkeletonTable } from "@/components/shared/LoadingSkeleton";
|
||||
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton variant="text" width="w-48" height="h-8" />
|
||||
<LoadingSkeleton variant="text" width="w-64" height="h-4" />
|
||||
</div>
|
||||
<LoadingSkeleton variant="button" />
|
||||
</div>
|
||||
|
||||
{/* Cards grid skeleton */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<LoadingSkeleton variant="card" />
|
||||
<LoadingSkeleton variant="card" />
|
||||
<LoadingSkeleton variant="card" />
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<SkeletonTable rows={8} cols={4} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { AdminUserRow } from "@/actions/admin/users";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AdminUserRow;
|
||||
};
|
||||
|
||||
export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [displayName, setDisplayName] = useState(currentUser.display_name ?? "");
|
||||
const [phoneNumber, setPhoneNumber] = useState(currentUser.phone_number ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailChangeSent, setEmailChangeSent] = useState(false);
|
||||
const [changingEmail, setChangingEmail] = useState(false);
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: rpcError } = await supabase.rpc("update_admin_user", {
|
||||
p_id: currentUser.id,
|
||||
p_display_name: displayName || null,
|
||||
p_phone_number: phoneNumber || null,
|
||||
});
|
||||
if (rpcError) {
|
||||
setError(rpcError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "profile_update",
|
||||
details: { fields: ["display_name", "phone_number"] },
|
||||
});
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailChange(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setChangingEmail(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: newEmail,
|
||||
});
|
||||
if (updateError) {
|
||||
setEmailError(updateError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "email_change",
|
||||
details: { new_email: newEmail },
|
||||
});
|
||||
setEmailChangeSent(true);
|
||||
setChangingEmail(false);
|
||||
} finally {
|
||||
setChangingEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Link href="/admin" className="text-sm text-zinc-500 hover:text-zinc-300">
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-6 text-3xl font-bold text-zinc-100">My Profile</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Manage your profile information and preferences.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-xl bg-red-900/30 p-4 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Profile card */}
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">
|
||||
{currentUser.display_name || currentUser.email}
|
||||
</h2>
|
||||
{currentUser.display_name && (
|
||||
<p className="text-sm text-zinc-500">{currentUser.email}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="rounded-full bg-zinc-950 px-2.5 py-0.5 text-xs font-semibold capitalize text-zinc-400">
|
||||
{currentUser.role.replace("_", " ")}
|
||||
</span>
|
||||
{currentUser.brand_name && (
|
||||
<span className="text-xs text-zinc-500">{currentUser.brand_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded-lg border border-zinc-800 px-4 py-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Read-only info when not editing */}
|
||||
{!editing && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">Phone</p>
|
||||
<p className="mt-1 text-sm text-zinc-300">{currentUser.phone_number ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">Email</p>
|
||||
<p className="mt-1 text-sm text-zinc-300">{currentUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit form */}
|
||||
{editing && (
|
||||
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-slate-500"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-slate-500"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setError(null); }}
|
||||
className="rounded-lg border border-zinc-800 px-4 py-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email change section */}
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h3 className="text-base font-semibold text-zinc-100">Email Address</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Current: <span className="font-medium text-zinc-300">{currentUser.email}</span>
|
||||
</p>
|
||||
|
||||
{emailChangeSent ? (
|
||||
<div className="mt-4 rounded-lg bg-green-900/30 p-4 text-sm text-green-400">
|
||||
A confirmation email has been sent to <strong>{newEmail}</strong>. Click the link in the email to confirm the change.
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">New Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-slate-500"
|
||||
placeholder="new@example.com"
|
||||
/>
|
||||
</div>
|
||||
{emailError && (
|
||||
<div className="rounded-lg bg-red-900/30 p-3 text-sm text-red-400">{emailError}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changingEmail || !newEmail || !newEmail.includes("@")}
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{changingEmail ? "Sending…" : "Change Email"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminUsers } from "@/actions/admin/users";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import AdminMeClient from "./AdminMeClient";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminMePage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
// Fetch the current user's full admin row for display_name, phone_number, etc.
|
||||
const { users } = await getAdminUsers(adminUser.brand_id ?? undefined);
|
||||
const myUser = users.find((u) => u.user_id === adminUser.user_id);
|
||||
|
||||
if (!myUser) return <AdminAccessDenied message="Your admin account record was not found." />;
|
||||
|
||||
return <AdminMeClient currentUser={myUser} />;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrderDetail } from "@/actions/orders";
|
||||
import OrderEditForm from "@/components/admin/OrderEditForm";
|
||||
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
||||
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type OrderDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const order = await getAdminOrderDetail(id);
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Order not found</h1>
|
||||
<a
|
||||
href="/admin/orders"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Orders
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_orders) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser?.brand_id && order.stops?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
const brandId = order.stops?.brand_id ?? null;
|
||||
const subtotal = Number(order.subtotal);
|
||||
const discount_amount = Number(order.discount_amount ?? 0);
|
||||
const total = Math.max(0, subtotal - discount_amount);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
href="/admin/orders"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Orders
|
||||
</a>
|
||||
|
||||
{/* Customer info */}
|
||||
<div className="card mt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
Customer
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
||||
{order.customer_name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
order.pickup_complete
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-yellow-100 text-yellow-700"
|
||||
}`}
|
||||
>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</span>
|
||||
<OrderPickupAction
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
currentlyPickedUp={order.pickup_complete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{order.customer_phone && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Phone</p>
|
||||
<p className="mt-1 text-lg text-stone-950">{order.customer_phone}</p>
|
||||
</div>
|
||||
)}
|
||||
{order.customer_email && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Email</p>
|
||||
<p className="mt-1 text-lg text-stone-950">{order.customer_email}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop info */}
|
||||
{order.stops && (
|
||||
<div className="card mt-6">
|
||||
<p className="text-sm font-medium text-stone-500">Stop</p>
|
||||
<p className="mt-1 text-xl font-semibold text-stone-950">
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">{order.stops.date}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Order Items</h2>
|
||||
|
||||
{order.order_items && order.order_items.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{order.order_items.map((item: any) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b border-stone-200 pb-3 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-stone-950">
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</p>
|
||||
<p className="text-sm text-stone-500">Qty: {item.quantity}</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-950">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-stone-500">No items found.</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Subtotal</span>
|
||||
<span className="text-stone-950">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{Number(order.tax_amount ?? 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Tax ({order.tax_rate ? `${(Number(order.tax_rate) * 100).toFixed(3)}%` : ""})</span>
|
||||
<span className="text-stone-950">${Number(order.tax_amount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{discount_amount > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Discount</span>
|
||||
<span className="text-red-600">-${discount_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
{order.discount_reason && (
|
||||
<p className="text-xs text-stone-500">{order.discount_reason}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between text-xl font-bold text-stone-950 pt-2 border-t border-stone-200">
|
||||
<span>Total</span>
|
||||
<span>${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}</span>
|
||||
</div>
|
||||
{order.tax_location && (
|
||||
<p className="text-xs text-stone-500">Tax sourced from: {order.tax_location}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Refunds */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">
|
||||
Payment & Refunds
|
||||
</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Record payment details and manage refunds for this order.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<OrderPaymentSection
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
orderTotal={total}
|
||||
payment_processor={order.payment_processor}
|
||||
payment_status={order.payment_status}
|
||||
payment_transaction_id={order.payment_transaction_id}
|
||||
refunded_amount={order.refunded_amount ?? 0}
|
||||
refund_reason={order.refund_reason}
|
||||
existingRefunds={order.refunds ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit form */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Order</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Update customer details, pricing, and status.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<OrderEditForm order={order as any} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes display */}
|
||||
{order.internal_notes && (
|
||||
<div className="card mt-6 border-amber-300 bg-amber-50">
|
||||
<p className="text-sm font-medium text-amber-700">Internal Notes</p>
|
||||
<p className="mt-1 text-stone-950">{order.internal_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminOrdersPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_orders) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
|
||||
const brandStops = adminUser?.brand_id
|
||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
||||
: stops;
|
||||
|
||||
const brandOrders = adminUser?.brand_id
|
||||
? orders.filter(
|
||||
(o) =>
|
||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||
)
|
||||
: orders;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<AdminOrdersPanel
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import Link from "next/link";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getBrandPlanInfo } from "@/actions/billing/stripe-portal";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
type Section = {
|
||||
title: string;
|
||||
href: string;
|
||||
description: string;
|
||||
group: "operations" | "fulfillment" | "management" | "tools";
|
||||
addonKey?: string;
|
||||
upgradeText?: string;
|
||||
prominent?: boolean;
|
||||
};
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: "Orders",
|
||||
href: "/admin/orders",
|
||||
description: "View orders, pickup status, fulfillment, and customer details.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
href: "/admin/products",
|
||||
description: "Manage products, pricing, shipping type, and availability.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Tours & Stops",
|
||||
href: "/admin/stops",
|
||||
description: "Manage routes, pickup locations, dates, and cutoff times.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Driver Pickup",
|
||||
href: "/admin/pickup",
|
||||
description: "Mobile pickup lookup, QR scanning, and completion tools.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Shipping",
|
||||
href: "/admin/shipping",
|
||||
description: "FedEx integration, label creation, and shipment tracking.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
href: "/admin/reports",
|
||||
description: "Sales, route, product, pickup, and customer reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Tax Dashboard",
|
||||
href: "/admin/taxes",
|
||||
description: "Sales tax collected, breakdown by state, and exportable reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/admin/settings",
|
||||
description: "Users, billing, brand, integrations, payments, and shipping.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Harvest Reach",
|
||||
href: "/admin/communications",
|
||||
description: "Email campaigns, stop blast, templates, and audience segments.",
|
||||
group: "tools",
|
||||
addonKey: "harvest_reach",
|
||||
upgradeText: "Enable to access email & SMS marketing",
|
||||
},
|
||||
{
|
||||
title: "Wholesale Portal",
|
||||
href: "/admin/wholesale",
|
||||
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
|
||||
group: "tools",
|
||||
addonKey: "wholesale_portal",
|
||||
upgradeText: "Enable to unlock B2B buyer portal",
|
||||
},
|
||||
{
|
||||
title: "Import Center",
|
||||
href: "/admin/import",
|
||||
description: "AI-powered import for products, orders, contacts, and stops.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI import with smart column mapping",
|
||||
},
|
||||
{
|
||||
title: "AI Intelligence",
|
||||
href: "/admin/settings/ai",
|
||||
description: "Campaign writer, pricing advisor, and report explainer.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI tools for marketing and pricing",
|
||||
},
|
||||
{
|
||||
title: "Time Tracking",
|
||||
href: "/admin/time-tracking",
|
||||
description: "Worker clock-in/out, hours tracking, and overtime management.",
|
||||
group: "tools",
|
||||
addonKey: "time_tracking",
|
||||
upgradeText: "Enable for field worker time tracking",
|
||||
},
|
||||
{
|
||||
title: "Water Log",
|
||||
href: "/admin/water-log",
|
||||
description: "Irrigation tracking and water usage reporting.",
|
||||
group: "tools",
|
||||
addonKey: "water_log",
|
||||
upgradeText: "Enable for agricultural water tracking",
|
||||
},
|
||||
{
|
||||
title: "Route Trace",
|
||||
href: "/admin/route-trace",
|
||||
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
|
||||
group: "tools",
|
||||
addonKey: "route_trace",
|
||||
upgradeText: "Enable for field-to-delivery traceability",
|
||||
},
|
||||
];
|
||||
|
||||
const groupMeta = {
|
||||
operations: { label: "Core Operations", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
|
||||
fulfillment: { label: "Fulfillment & Logistics", cols: "grid-cols-1 md:grid-cols-3" },
|
||||
management: { label: "Business Management", cols: "grid-cols-1 md:grid-cols-3" },
|
||||
tools: { label: "Tools & Add-ons", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
|
||||
} as const;
|
||||
|
||||
export default async function AdminPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const isStoreEmployee = adminUser?.role === "store_employee";
|
||||
|
||||
const isWaterLogVisible =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
|
||||
const enabledAddons: Record<string, boolean> = {};
|
||||
let planTier = "starter";
|
||||
let usage = { users: 0, stops_this_month: 0, products: 0 };
|
||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||
let brandDisplayName = "Admin";
|
||||
if (adminUser?.brand_id) {
|
||||
for (const key of ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "sms_campaigns", "square_sync", "route_trace"] as const) {
|
||||
enabledAddons[key] = await isFeatureEnabled(adminUser.brand_id, key);
|
||||
}
|
||||
const planResult = await getBrandPlanInfo(adminUser.brand_id);
|
||||
if (planResult.success && planResult.data) {
|
||||
planTier = planResult.data.plan_tier ?? "starter";
|
||||
usage = planResult.data.usage ?? usage;
|
||||
limits = {
|
||||
max_users: planResult.data.max_users ?? 1,
|
||||
max_stops_monthly: planResult.data.max_stops_monthly ?? 10,
|
||||
max_products: planResult.data.max_products ?? 25,
|
||||
};
|
||||
if (planResult.data.brand_name) brandDisplayName = planResult.data.brand_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStoreEmployee) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-stone-950">Driver Pickup</h1>
|
||||
<p className="mt-3 text-sm text-stone-600">
|
||||
Use the buttons below to look up orders, record pickups, and manage fulfillment.
|
||||
</p>
|
||||
<div className="mt-10 grid gap-5 md:grid-cols-2">
|
||||
<Link
|
||||
href="/admin/pickup"
|
||||
className="block rounded-2xl border border-stone-200 bg-white p-6 shadow-sm transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 transition-colors">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-stone-950">Pickup Lookup</h2>
|
||||
<p className="mt-0.5 text-sm text-stone-500">Scan QR or search orders</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/wholesale"
|
||||
className="block rounded-2xl border border-stone-200 bg-white p-6 shadow-sm transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 transition-colors">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-stone-950">Wholesale Portal</h2>
|
||||
<p className="mt-0.5 text-sm text-stone-500">Approved buyer orders</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const allGroups = [
|
||||
{
|
||||
key: "operations" as const,
|
||||
label: groupMeta.operations.label,
|
||||
cols: groupMeta.operations.cols,
|
||||
sections: sections.filter((s) => s.group === "operations"),
|
||||
},
|
||||
{
|
||||
key: "fulfillment" as const,
|
||||
label: groupMeta.fulfillment.label,
|
||||
cols: groupMeta.fulfillment.cols,
|
||||
sections: sections.filter((s) => s.group === "fulfillment"),
|
||||
},
|
||||
{
|
||||
key: "management" as const,
|
||||
label: groupMeta.management.label,
|
||||
cols: groupMeta.management.cols,
|
||||
sections: sections.filter((s) => s.group === "management"),
|
||||
},
|
||||
{
|
||||
key: "tools" as const,
|
||||
label: groupMeta.tools.label,
|
||||
cols: groupMeta.tools.cols,
|
||||
sections: sections.filter((s) => s.group === "tools"),
|
||||
},
|
||||
];
|
||||
|
||||
const usagePct = {
|
||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||
stops: limits.max_stops_monthly > 0 ? (usage.stops_this_month / limits.max_stops_monthly) * 100 : 0,
|
||||
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl space-y-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600 mb-2">Control Center</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950">{brandDisplayName}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/settings/billing" className="text-sm font-medium text-stone-500 hover:text-stone-700 transition-colors">
|
||||
Billing →
|
||||
</Link>
|
||||
{planTier === "starter" && (
|
||||
<Link
|
||||
href="/admin/settings/billing"
|
||||
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
|
||||
>
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bar */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center gap-5 mb-5">
|
||||
<span className={`rounded-full px-4 py-1.5 text-xs font-semibold tracking-wide ${
|
||||
planTier === "enterprise" ? "bg-amber-100 text-amber-700 border border-amber-200" :
|
||||
planTier === "farm" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" :
|
||||
"bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
||||
</span>
|
||||
<span className="text-sm text-stone-500">{adminUser?.brand_id ? "Tuxedo Corn" : "All Brands"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
|
||||
{ label: "Stops / month", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-stone-600">{label}</span>
|
||||
<span className="text-sm font-semibold text-stone-900">{value}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-stone-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
pct > 85 ? "bg-gradient-to-r from-amber-500 to-amber-400" : "bg-gradient-to-r from-emerald-500 to-emerald-400"
|
||||
}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped sections */}
|
||||
{allGroups.map(({ key, label, cols, sections: groupSections }) => {
|
||||
if (groupSections.length === 0) return null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-stone-500 flex-shrink-0">{label}</p>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-stone-300 to-transparent" />
|
||||
</div>
|
||||
<div className={`grid ${cols} gap-4`}>
|
||||
{groupSections.map((section) => {
|
||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
||||
|
||||
const isAddon = Boolean(section.addonKey);
|
||||
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
|
||||
const isProminent = section.prominent;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"group relative flex flex-col rounded-2xl border bg-white p-5 transition-all hover:-translate-y-0.5",
|
||||
isAddon && !isEnabled
|
||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
||||
: isProminent
|
||||
? "border-stone-200 shadow-md shadow-stone-200/50 hover:shadow-lg"
|
||||
: "border-stone-200 shadow-sm hover:shadow-md",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${
|
||||
isAddon && !isEnabled
|
||||
? "bg-stone-100 text-stone-400"
|
||||
: isProminent
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-blue-100 text-blue-600"
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isAddon && !isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
|
||||
Add-on
|
||||
</span>
|
||||
)}
|
||||
{isAddon && isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{isProminent && (
|
||||
<span className="text-[10px] font-semibold text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Core
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold leading-tight text-stone-950">
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
<p className={`mt-2 text-sm leading-relaxed ${
|
||||
isAddon && !isEnabled ? "text-stone-400" : "text-stone-500"
|
||||
}`}>
|
||||
{section.description}
|
||||
</p>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="mt-3 text-xs text-amber-600 font-medium">{section.upgradeText}</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import DriverPickupPanel from "@/components/admin/DriverPickupPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DriverPickupPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
|
||||
// Filter stops by brand if scoped
|
||||
const brandStops = adminUser?.brand_id
|
||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
||||
: stops;
|
||||
|
||||
// Build stop IDs for brand filtering
|
||||
const brandStopIds = brandStops.map((s) => s.id);
|
||||
|
||||
// Filter orders by brand
|
||||
const brandOrders = adminUser?.brand_id
|
||||
? orders.filter((o) => o.stop_id && brandStopIds.includes(o.stop_id))
|
||||
: orders;
|
||||
|
||||
const pendingOrders = brandOrders.filter((o) => !o.pickup_complete);
|
||||
const pickedUpOrders = brandOrders.filter(
|
||||
(o) =>
|
||||
o.pickup_complete &&
|
||||
o.pickup_completed_at &&
|
||||
new Date(o.pickup_completed_at) >
|
||||
new Date(Date.now() - 72 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
return (
|
||||
<DriverPickupPanel
|
||||
initialPendingOrders={pendingOrders}
|
||||
initialPickedUpOrders={pickedUpOrders}
|
||||
initialStops={brandStops}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
canManagePickup={adminUser?.can_manage_pickup ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductEditForm from "@/components/admin/ProductEditForm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type ProductDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [{ data: product, error }, { data: brands }] = await Promise.all([
|
||||
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(),
|
||||
supabase.from("brands").select("id, name, slug"),
|
||||
]);
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Product not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Product not found"}
|
||||
</pre>
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
{product.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-stone-600">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
product.active
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-stone-200 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Price</p>
|
||||
<p className="mt-1 text-2xl font-bold text-stone-950">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Type</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{product.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Product</h2>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Update product details, pricing, and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<ProductEditForm
|
||||
product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: Number(product.price),
|
||||
type: product.type,
|
||||
active: product.active,
|
||||
brand_id: product.brand_id,
|
||||
image_url: product.image_url,
|
||||
is_taxable: product.is_taxable,
|
||||
pickup_type: product.pickup_type,
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseProductCSV } from "@/lib/csv-parsers";
|
||||
import { importProductsBatch } from "@/actions/import-products";
|
||||
|
||||
type PreviewRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string;
|
||||
_rowIndex: number;
|
||||
_warnings: string[];
|
||||
};
|
||||
|
||||
export default function ProductImportPage() {
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
|
||||
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ created: number; updated: number; errors: { product: string; error: string }[] } | null>(null);
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const SAMPLE_CSV = `name,description,price,type,active,image_url
|
||||
Dozen Sweet Corn,Fresh picked sweet corn,12.99,Pickup,TRUE,
|
||||
Corn & Butter Bundle,Dozen corn with herb butter,18.99,Pickup & Shipping,TRUE,
|
||||
Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
`;
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
setCsvText(text);
|
||||
handlePreview(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function handlePreview(text?: string) {
|
||||
const toParse = text ?? csvText;
|
||||
if (!toParse.trim()) return;
|
||||
|
||||
const parseResult = await parseProductCSV(toParse);
|
||||
if (!parseResult.success) {
|
||||
setPreview(null);
|
||||
setParseErrors([{ row: 0, error: parseResult.error }]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(parseResult.rows);
|
||||
setParseErrors(parseResult.errors);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!preview || !brandId) return;
|
||||
setImporting(true);
|
||||
const importResult = await importProductsBatch(
|
||||
brandId,
|
||||
preview.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
price: r.price,
|
||||
type: r.type,
|
||||
active: r.active,
|
||||
image_url: r.image_url,
|
||||
}))
|
||||
);
|
||||
setImporting(false);
|
||||
if (importResult.success) {
|
||||
setResult({ created: importResult.created, updated: importResult.updated, errors: importResult.errors });
|
||||
} else {
|
||||
setResult({ created: 0, updated: 0, errors: [{ product: "", error: importResult.error }] });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<Link href="/admin/products" className="text-sm text-stone-500 hover:text-stone-700">
|
||||
← Back to Products
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">Import Products</h1>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Upload a CSV file to bulk-create or update products. Matching by product name within brand.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCsvText(SAMPLE_CSV);
|
||||
handlePreview(SAMPLE_CSV);
|
||||
}}
|
||||
className="rounded-xl border border-stone-300 px-4 py-2 text-sm text-stone-600 hover:bg-stone-200"
|
||||
>
|
||||
Load Sample CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Brand ID */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-stone-700">Brand ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de (Tuxedo)"
|
||||
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<label className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFile}
|
||||
className="w-full text-sm text-stone-500"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
Or paste CSV content below:
|
||||
</p>
|
||||
<textarea
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
rows={6}
|
||||
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500"
|
||||
placeholder="name,description,price,type,active,image_url"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handlePreview()}
|
||||
className="mt-3 rounded-xl border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-100"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Parse errors */}
|
||||
{parseErrors.length > 0 && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 p-4">
|
||||
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{parseErrors.map((e) => (
|
||||
<li key={e.row} className="text-sm text-red-600">
|
||||
Row {e.row}: {e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview !== null && (
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-stone-950">
|
||||
Preview ({preview.length} rows)
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!brandId || importing}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-500"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${preview.length} Products`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200">
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Name</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Price</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Type</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Active</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row) => (
|
||||
<tr key={row._rowIndex} className="border-b border-stone-200">
|
||||
<td className="py-2 px-3 font-medium text-stone-950">{row.name}</td>
|
||||
<td className="py-2 px-3 text-stone-600">${row.price.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.type}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.active ? "Yes" : "No"}</td>
|
||||
<td className="max-w-[120px] truncate py-2 px-3 text-stone-500">
|
||||
{row.image_url ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result !== null && (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
|
||||
<div className="mt-3 flex gap-6">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-emerald-600">{result.created}</p>
|
||||
<p className="text-sm text-stone-500">Created</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-blue-600">{result.updated}</p>
|
||||
<p className="text-sm text-stone-500">Updated</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-red-600">{result.errors.length}</p>
|
||||
<p className="text-sm text-stone-500">Errors</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<ul className="mt-3 space-y-1">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i} className="text-sm text-red-600">
|
||||
{e.product && `${e.product}: `}{e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import NewProductForm from "@/components/admin/NewProductForm";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function NewProductPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser?.can_manage_products) redirect("/admin/pickup");
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h1 className="text-3xl font-bold text-stone-950">
|
||||
Create Product
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-stone-500">
|
||||
Add a new product for Tuxedo Corn or Indian River Direct.
|
||||
</p>
|
||||
|
||||
<NewProductForm />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductTableClient from "@/components/admin/ProductTableClient";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import ProductSyncBanner from "@/components/admin/ProductSyncBanner";
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [settingsResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
]);
|
||||
const hasSquareToken = !!(
|
||||
settingsResult.success && settingsResult.settings?.square_access_token
|
||||
);
|
||||
|
||||
let query = supabase
|
||||
.from("products")
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
type,
|
||||
active,
|
||||
deleted_at,
|
||||
image_url,
|
||||
brand_id,
|
||||
is_taxable,
|
||||
brands (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.is("deleted_at", null)
|
||||
.order("name");
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
}
|
||||
|
||||
const { data: products, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-red-600">Error loading products</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Products</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
Manage your product catalog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/products/new"
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 font-semibold text-white text-sm transition-colors shadow-sm"
|
||||
>
|
||||
Add Product
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
|
||||
<ProductSyncBanner brandId={brandId} hasSquareToken={hasSquareToken} />
|
||||
<ProductTableClient products={products ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ReportsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_reports) redirect("/admin/pickup");
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
const { data: brands } = await supabase
|
||||
.from("brands")
|
||||
.select("id, name")
|
||||
.order("name");
|
||||
|
||||
const initialBrandId = isPlatformAdmin
|
||||
? null
|
||||
: adminUser.brand_id ?? null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Reports</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
Operational visibility across orders, fulfillment, contacts, and campaigns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ReportsDashboard
|
||||
brands={brands ?? []}
|
||||
initialBrandId={initialBrandId}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
brandId={adminUser.brand_id}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import AdminLookupPage from "@/components/route-trace/AdminLookupPage";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export default async function LookupPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">Lookup</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Find lots by lot number or crop type</p>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lookup" />
|
||||
<AdminLookupPage brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
|
||||
import LotDetailPanel from "@/components/route-trace/LotDetailPanel";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export default async function LotDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [detailResult, ordersResult] = await Promise.all([
|
||||
getRouteTraceLotDetail(id),
|
||||
getLotOrders(id),
|
||||
]);
|
||||
|
||||
if (!detailResult.success || !detailResult.lot) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="rounded-xl border border-red-200 bg-zinc-900 p-6 text-center">
|
||||
<p className="text-red-600">Lot not found</p>
|
||||
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-blue-400 hover:underline">← Back to Lots</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6">
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300">← Back to Lots</a>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotDetailPanel
|
||||
lot={detailResult.lot}
|
||||
brandId={effectiveBrandId}
|
||||
orders={ordersResult.success ? ordersResult.orders : []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import LotCreateForm from "@/components/route-trace/LotCreateForm";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export default async function NewLotPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300">← Back to Lots</a>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotCreateForm brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLots } from "@/actions/route-trace/lots";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
import LotListTable from "@/components/route-trace/LotListTable";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function LotsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const result = await getRouteTraceLots(effectiveBrandId);
|
||||
const lots = result.success ? result.lots : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">Lots</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">{lots.length} lot{lots.length !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/route-trace/lots/new"
|
||||
className="rounded-xl bg-stone-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
+ New Lot
|
||||
</Link>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotListTable initialLots={lots} brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user