Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
52 changes: 52 additions & 0 deletions app/(authenticated)/discounts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { requireAdmin } from "@/lib/auth/require-admin";
import {
applyProductDiscountToCustomer,
removeProductDiscountFromCustomer,
listStripeServices,
listActiveDiscountsForCustomer,
deleteCoupon,
} from "@/lib/stripe";
import type { ActiveDiscount, DiscountService } from "@/components/discount-modal";

export type DiscountActionState = {
errors?: Record<string, string[]>;
Expand Down Expand Up @@ -148,3 +152,51 @@ 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 };
}

export async function removeCouponById(couponId: string): Promise<DiscountActionState> {
Comment thread
martin0024 marked this conversation as resolved.
Outdated
try {
await requireAdmin();
} catch {
return { errors: { _form: ["Unauthorized"] } };
}
try {
await deleteCoupon(couponId);
return { message: "Discount removed." };
} catch (error) {
return { errors: { _form: [error instanceof Error ? error.message : "Could not remove discount"] } };
}
}
103 changes: 103 additions & 0 deletions app/(authenticated)/users/_components/user-actions-cell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { 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 { 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 fetchModalData = async () => {
if (!user.stripeCustomerId) return;
setLoading(true);
const data = await getUserDiscountModalData(user.stripeCustomerId);
setServices(data.services);
setDiscounts(data.discounts);
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) await fetchModalData();
};

const handleRemove = async (couponId: string) => {
const result = await removeCouponById(couponId);
if (!result?.errors) 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
Loading
Loading