Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions app/(authenticated)/discounts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { requireAdmin } from "@/lib/auth/require-admin";
import {
applyProductDiscountToCustomer,
removeProductDiscountFromCustomer,
listStripeServices,
listActiveDiscountsForCustomer,
deleteCoupon,
stripe,
} from "@/lib/stripe";
import type { ActiveDiscount, DiscountService } from "@/components/discount-modal";

export type DiscountActionState = {
errors?: Record<string, string[]>;
Expand Down Expand Up @@ -148,3 +153,69 @@ export async function removeDiscountFromCustomerProduct(
};
}
}

export async function getUserDiscountModalData(stripeCustomerId: string): Promise<{
services: DiscountService[];
discounts: ActiveDiscount[];
}> {
try {
await requireAdmin();
} catch {
return { services: [], discounts: [] };
}

const [rawServices, rawDiscounts] = await Promise.all([
listStripeServices(),
listActiveDiscountsForCustomer(stripeCustomerId),
]);

const nameMap = new Map(rawServices.map((s) => [s.id, s.name]));

const discounts: ActiveDiscount[] = rawDiscounts.map((d) => ({
id: d.couponId,
service: nameMap.get(d.productId) ?? d.productId,
type: d.percentOff !== null ? "Percent" : "Amount",
value:
d.percentOff !== null
? `${d.percentOff}%`
: `$${((d.amountOffCents ?? 0) / 100).toFixed(2)}`,
used:
d.maxRedemptions !== null
? `${d.timesRedeemed} / ${d.maxRedemptions}`
: String(d.timesRedeemed),
}));

return { services: rawServices, discounts };
}

const removeCouponByIdSchema = z.object({
couponId: z.string().min(1, "Coupon ID is required"),
customerId: z.string().min(1, "Customer ID is required"),
});

export async function removeCouponById(
couponId: string,
customerId: string,
): Promise<DiscountActionState> {
try {
await requireAdmin();
} catch {
return { errors: { _form: ["Unauthorized"] } };
}

const parsed = removeCouponByIdSchema.safeParse({ couponId, customerId });
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}

try {
const coupon = await stripe.coupons.retrieve(parsed.data.couponId);
if (coupon.metadata?.customerId !== parsed.data.customerId) {
return { errors: { _form: ["This discount does not belong to the specified customer"] } };
}
await deleteCoupon(parsed.data.couponId);
return { message: "Discount removed." };
} catch (error) {
return { errors: { _form: [error instanceof Error ? error.message : "Could not remove discount"] } };
}
}
2 changes: 2 additions & 0 deletions app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import { AppSidebar } from "@/components/app-sidebar";

async function AuthGate({ children }: { children: React.ReactNode }) {
Expand Down Expand Up @@ -35,6 +36,7 @@ export default function AuthenticatedLayout({
</div>
</SidebarInset>
</SidebarProvider>
<Toaster />
</TooltipProvider>
);
}
126 changes: 126 additions & 0 deletions app/(authenticated)/users/_components/user-actions-cell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"use client";

import { useRef, useState } from "react";
import { Pencil, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DiscountModal, type ActiveDiscount, type DiscountService } from "@/components/discount-modal";
import {
getUserDiscountModalData,
applyDiscountToCustomerProduct,
removeCouponById,
} from "@/app/(authenticated)/discounts/actions";
import { toast } from "sonner";
import { profileRoleLabel, type UserRow } from "../profile-role-label";

interface UserActionsCellProps {
user: UserRow;
}

export function UserActionsCell({ user }: UserActionsCellProps) {
const [open, setOpen] = useState(false);
const [services, setServices] = useState<DiscountService[]>([]);
const [discounts, setDiscounts] = useState<ActiveDiscount[]>([]);
const [loading, setLoading] = useState(false);
const servicesFetched = useRef(false);

const fetchModalData = async (forceServices = false) => {
if (!user.stripeCustomerId) return;
setLoading(true);
try {
const data = await getUserDiscountModalData(user.stripeCustomerId);
if (forceServices || !servicesFetched.current) {
setServices(data.services);
servicesFetched.current = true;
}
setDiscounts(data.discounts);
} finally {
setLoading(false);
}
};

const handleOpenChange = async (next: boolean) => {
setOpen(next);
if (next) await fetchModalData();
};

const handleApply = async (data: {
Comment thread
martin0024 marked this conversation as resolved.
serviceId: string;
type: "percent" | "amount";
value: number;
usageLimit: number;
}) => {
if (!user.stripeCustomerId) return;
const fd = new FormData();
fd.append("product_id", data.serviceId);
fd.append("customer_id", user.stripeCustomerId);
fd.append("usage_limit", String(data.usageLimit));
fd.append("discount_type", data.type);
const discountValue = data.type === "amount" ? Math.round(parseFloat(data.value.toFixed(2)) * 100) : data.value;
fd.append("discount_value", String(discountValue));
if (data.type === "amount") fd.append("currency", "cad");
const result = await applyDiscountToCustomerProduct(null, fd);
if (result?.errors) {
toast.error("Failed to apply discount", {
description: Object.values(result.errors).flat().join(" "),
});
} else {
toast.success("Discount applied");
await fetchModalData();
}
};

const handleRemove = async (couponId: string) => {
if (!user.stripeCustomerId) return;
const result = await removeCouponById(couponId, user.stripeCustomerId);
if (result?.errors) {
toast.error("Failed to remove discount", {
description: Object.values(result.errors).flat().join(" "),
});
} else {
toast.success("Discount removed");
setDiscounts((prev) => prev.filter((d) => d.id !== couponId));
}
};

return (
<div className="flex items-center justify-end gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="Edit user" disabled>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Edit (coming soon)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Manage discounts"
disabled={!user.stripeCustomerId || loading}
onClick={() => handleOpenChange(true)}
>
<Tag />
</Button>
</TooltipTrigger>
<TooltipContent>
{user.stripeCustomerId ? "Manage discounts" : "No Stripe customer"}
</TooltipContent>
</Tooltip>
<DiscountModal
userName={`${user.firstName} ${user.lastName}`}
userEmail={user.email}
userRole={profileRoleLabel(user.role)}
discounts={discounts}
services={services}
loading={loading}
open={open}
onOpenChange={handleOpenChange}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-hits Stripe on every open no caching?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrap listStripeServices in unstable_cache since service catalogs change rarely, right now on modal open it does 2 stripe api calls products +prices.

onApply={handleApply}
onRemove={handleRemove}
/>
</div>
);
}
40 changes: 2 additions & 38 deletions app/(authenticated)/users/_components/users-columns.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
"use client";

import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback} from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";

import { profileRoleLabel, type UserRow } from "../profile-role-label";
import { UserActionsCell } from "./user-actions-cell";

export const usersColumns: ColumnDef<UserRow>[] = [
{
Expand Down Expand Up @@ -95,35 +88,6 @@ export const usersColumns: ColumnDef<UserRow>[] = [
thClassName: "text-right",
tdClassName: "text-right",
},
cell: () => (
<div className="flex items-center justify-end gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Edit user"
disabled
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Edit (coming soon)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Give discount"
disabled
>
<Tag />
</Button>
</TooltipTrigger>
<TooltipContent>Give coupon (coming soon)</TooltipContent>
</Tooltip>
</div>
),
cell: ({ row }) => <UserActionsCell user={row.original} />,
},
];
1 change: 1 addition & 0 deletions app/(authenticated)/users/profile-role-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export type UserRow = {
role: string;
isActive: boolean;
lastLoginAt: Date;
stripeCustomerId: string | null;
};
2 changes: 2 additions & 0 deletions app/(authenticated)/users/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function listUsersWithEmails(): Promise<UserRow[]> {
lastName: profiles.lastName,
role: profiles.role,
lastLoginAt: profiles.lastLoginAt,
stripeCustomerId: profiles.stripeCustomerId,
subscriptionStatus: subscriptions.status,
email: authUsers.email,
})
Expand All @@ -36,6 +37,7 @@ export async function listUsersWithEmails(): Promise<UserRow[]> {
lastName: row.lastName,
role: row.role,
lastLoginAt: row.lastLoginAt,
stripeCustomerId: row.stripeCustomerId ?? null,
email: row.email ?? "",
isActive: row.subscriptionStatus === USERS_SUBSCRIPTION_STATUS_ACTIVE,
}));
Expand Down
7 changes: 5 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { ThemeProvider } from "next-themes";
import "./globals.css";

export const metadata: Metadata = {
Expand All @@ -12,9 +13,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full antialiased">
<html lang="en" className="h-full antialiased" suppressHydrationWarning>
<body className="min-h-full flex flex-col font-[Helvetica,Arial,sans-serif]">
{children}
<ThemeProvider attribute="class" defaultTheme="light" forcedTheme="light">
{children}
</ThemeProvider>
</body>
</html>
);
Expand Down
Loading
Loading