revert: Remove accordion grouping + add AlertDialog

1. Reverted Accordion Grouping 
   Problem: Payment titles are editable by users
   - User renames "BNI Virtual Account" to "BNI VA 2"
   - Grouping breaks - gateway moves to new accordion
   - Confusing UX when titles change

   Solution: Back to flat list
   - All payment methods in one list
   - Titles can be edited without breaking layout
   - Simpler, more predictable behavior

2. Added AlertDialog Component 
   Installed: @radix-ui/react-alert-dialog
   Created: alert-dialog.tsx (shadcn pattern)

   Use for confirmations:
   - "Are you sure you want to delete?"
   - "Discard unsaved changes?"
   - "Disable payment method?"

   Example:
   <AlertDialog>
     <AlertDialogTrigger>Delete</AlertDialogTrigger>
     <AlertDialogContent>
       <AlertDialogHeader>
         <AlertDialogTitle>Are you sure?</AlertDialogTitle>
         <AlertDialogDescription>
           This action cannot be undone.
         </AlertDialogDescription>
       </AlertDialogHeader>
       <AlertDialogFooter>
         <AlertDialogCancel>Cancel</AlertDialogCancel>
         <AlertDialogAction>Delete</AlertDialogAction>
       </AlertDialogFooter>
     </AlertDialogContent>
   </AlertDialog>

Shadcn Dialog Components:
 Dialog - Forms, settings (@radix-ui/react-dialog)
 Drawer - Mobile bottom sheet (vaul)
 AlertDialog - Confirmations (@radix-ui/react-alert-dialog)

All three are official shadcn components!
This commit is contained in:
dwindown
2025-11-06 10:20:43 +07:00
parent b1b4f56b47
commit 108155db50
4 changed files with 221 additions and 84 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -1191,6 +1192,34 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { CreditCard, Banknote, Settings, RefreshCw, ExternalLink, Loader2, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
@@ -117,16 +116,6 @@ export default function PaymentsPage() {
// Combine provider and other into single "3rd party" category
const thirdPartyGateways = gateways.filter((g: PaymentGateway) => g.type === 'provider' || g.type === 'other');
// Group third party gateways by provider (title)
const gatewaysByProvider = thirdPartyGateways.reduce((acc: Record<string, PaymentGateway[]>, gateway: PaymentGateway) => {
const provider = gateway.title || 'Other';
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(gateway);
return acc;
}, {});
if (isLoading) {
return (
<SettingsLayout title="Payments" description="Manage how you get paid">
@@ -208,45 +197,28 @@ export default function PaymentsPage() {
)}
</SettingsCard>
{/* 3rd Party Payment Methods - Grouped by provider */}
{Object.keys(gatewaysByProvider).length > 0 && (
{/* Online Payment Methods - Flat list */}
{thirdPartyGateways.length > 0 && (
<SettingsCard
title="Online Payment Methods"
description="Accept credit cards, digital wallets, and other online payments"
>
<Accordion type="multiple" className="w-full">
{(Object.entries(gatewaysByProvider) as [string, PaymentGateway[]][]).map(([provider, providerGateways]) => (
<AccordionItem key={provider} value={provider}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<CreditCard className="h-5 w-5" />
</div>
<div className="text-left">
<h3 className="font-semibold">{provider}</h3>
<p className="text-sm text-muted-foreground font-normal">
{providerGateways.length} payment {providerGateways.length === 1 ? 'method' : 'methods'}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 pt-2">
{providerGateways.map((gateway: PaymentGateway) => (
<div className="space-y-4">
{thirdPartyGateways.map((gateway: PaymentGateway) => (
<div
key={gateway.id}
className="border rounded-lg p-4 hover:border-primary/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<div className={`p-2 rounded-lg ${gateway.enabled ? 'bg-green-500/20 text-green-500' : 'bg-muted text-muted-foreground'}`}>
<CreditCard className="h-5 w-5" />
<div className={`p-2 rounded-lg ${gateway.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
<CreditCard className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">
{gateway.method_title}
</h4>
<h3 className="font-semibold">
{gateway.method_title || gateway.title}
</h3>
</div>
{gateway.method_description && (
<p className="text-sm text-muted-foreground mb-2">
@@ -285,10 +257,6 @@ export default function PaymentsPage() {
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</SettingsCard>
)}
</SettingsLayout>