From 543b1d65eb23d6a6237a7387537d688ca628f20e Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 4 Aug 2026 06:14:24 -0700 Subject: [PATCH] refactor(settings): decompose settings-client into panels (slice 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 663-line settings-client.tsx → a 79-line orchestrator (activeTab + shared save status) + settings/panels/{space,integrations,dues,webhooks}-panel.tsx each owning its own form state + types.ts. Server-action calls and fields preserved verbatim. tsc 0, build ok. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(app)/settings/panels/dues-panel.tsx | 14 + .../settings/panels/integrations-panel.tsx | 247 +++++++ app/(app)/settings/panels/space-panel.tsx | 244 +++++++ app/(app)/settings/panels/webhooks-panel.tsx | 130 ++++ app/(app)/settings/settings-client.tsx | 606 +----------------- app/(app)/settings/types.ts | 43 ++ 6 files changed, 689 insertions(+), 595 deletions(-) create mode 100644 app/(app)/settings/panels/dues-panel.tsx create mode 100644 app/(app)/settings/panels/integrations-panel.tsx create mode 100644 app/(app)/settings/panels/space-panel.tsx create mode 100644 app/(app)/settings/panels/webhooks-panel.tsx create mode 100644 app/(app)/settings/types.ts diff --git a/app/(app)/settings/panels/dues-panel.tsx b/app/(app)/settings/panels/dues-panel.tsx new file mode 100644 index 0000000..11258de --- /dev/null +++ b/app/(app)/settings/panels/dues-panel.tsx @@ -0,0 +1,14 @@ +'use client' + +import { StripeBillingPanel } from '@/components/settings/stripe-billing-panel' +import { DuesPaymentMethodsPanel } from '@/components/settings/dues-payment-methods-panel' + +// Dues Tab: how members pay dues (Stripe recurring + external links) +export function DuesPanel({ spaceId }: { spaceId: string }) { + return ( +
+ + +
+ ) +} diff --git a/app/(app)/settings/panels/integrations-panel.tsx b/app/(app)/settings/panels/integrations-panel.tsx new file mode 100644 index 0000000..6df8a3c --- /dev/null +++ b/app/(app)/settings/panels/integrations-panel.tsx @@ -0,0 +1,247 @@ +'use client' + +import { useState } from 'react' +import { Check, Eye, EyeOff } from 'lucide-react' +import { saveIntegration, disconnectIntegration } from '@/lib/actions' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { IntegrationConfig, Integration, SaveStatus } from '../types' + +const INTEGRATIONS_CONFIG: IntegrationConfig[] = [ + { + platform: 'paypal', + name: 'PayPal', + description: 'Sync transactions for member dues', + icon: ( + + + + ), + fields: [ + { key: 'client_id', label: 'Client ID', type: 'text', placeholder: 'Sandbox or Live Client ID' }, + { key: 'client_secret', label: 'Client Secret', type: 'password', placeholder: 'Client Secret from PayPal Developer' }, + { key: 'mode', label: 'Mode', type: 'select', options: ['sandbox', 'live'] }, + ], + docs: 'https://developer.paypal.com/api/rest/', + }, + { + platform: 'zeffy', + name: 'Zeffy', + description: 'Nonprofit payment and donation sync', + icon: ( + + + + ), + fields: [ + { key: 'api_key', label: 'API Key', type: 'password', placeholder: 'Zeffy API Key' }, + { key: 'organization_id', label: 'Organization ID', type: 'text', placeholder: 'Your Zeffy Org ID' }, + ], + docs: 'https://docs.zeffy.com/', + }, + { + platform: 'venmo', + name: 'Venmo', + description: 'Pull Venmo business transactions', + icon: ( + + + + ), + fields: [ + { key: 'access_token', label: 'Access Token', type: 'password', placeholder: 'Venmo API Access Token' }, + ], + docs: 'https://developer.venmo.com/', + }, + // Stripe is intentionally NOT listed here. Stripe recurring dues is + // configured in the dedicated Dues tab (saveStripeSettings: mode, tier->price + // map, grace days, vaulted keys). The generic saveIntegration path OVERWRITES + // integrations.config with a different shape, which would clobber the dues + // config (drop prices/mode/grace_days). One Stripe config UI, in Dues. +] + +interface Props extends SaveStatus { + integrations: Integration[] + isAdmin: boolean +} + +export function IntegrationsPanel({ integrations, isAdmin, saving, setSaving, setMessage }: Props) { + const [editingIntegration, setEditingIntegration] = useState(null) + const [integrationForm, setIntegrationForm] = useState>({}) + const [showSecrets, setShowSecrets] = useState>({}) + + async function handleSaveIntegration(e: React.FormEvent) { + e.preventDefault() + if (!editingIntegration || !isAdmin) return + setSaving(true) + setMessage(null) + const result = await saveIntegration(editingIntegration.platform, integrationForm) + if (result.error) { + setMessage({ type: 'error', text: result.error }) + } else { + setMessage({ type: 'success', text: `${editingIntegration.name} connected!` }) + setEditingIntegration(null) + } + setSaving(false) + } + + async function handleDisconnect(platform: string) { + if (!isAdmin) return + setSaving(true) + await disconnectIntegration(platform) + setSaving(false) + } + + function openIntegrationModal(config: IntegrationConfig) { + const existing = integrations.find(i => i.platform === config.platform) + const initialForm: Record = {} + config.fields.forEach(f => { + initialForm[f.key] = (existing?.config as Record | undefined)?.[f.key] || '' + }) + setIntegrationForm(initialForm) + setEditingIntegration(config) + } + + const getIntegrationStatus = (platform: string) => integrations.find(i => i.platform === platform)?.is_connected ?? false + + return ( + <> +
+

Integrations

+ +
+ {INTEGRATIONS_CONFIG.map(config => { + const connected = getIntegrationStatus(config.platform) + return ( +
+
{config.icon}
+
+

{config.name}

+

{config.description}

+
+
+ {connected ? ( + <> + + Connected + + {isAdmin && ( + + )} + + ) : isAdmin ? ( + + ) : ( + Not connected + )} +
+
+ ) + })} +
+
+ + {/* Integration Config Modal */} + { if (!o) setEditingIntegration(null) }}> + + {editingIntegration && ( + <> + + +
{editingIntegration.icon}
+ {editingIntegration.name} +
+
+ +
+ {editingIntegration.fields.map(field => ( +
+ + {field.type === 'select' ? ( + + ) : ( +
+ setIntegrationForm(f => ({ ...f, [field.key]: e.target.value }))} + placeholder={field.placeholder} + className="w-full bg-background border border-border rounded px-3 py-2 pr-10 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary" + /> + {field.type === 'password' && ( + + )} +
+ )} +
+ ))} + + + +
+ {getIntegrationStatus(editingIntegration.platform) && ( + + )} + + +
+
+ + )} +
+
+ + ) +} diff --git a/app/(app)/settings/panels/space-panel.tsx b/app/(app)/settings/panels/space-panel.tsx new file mode 100644 index 0000000..15c0109 --- /dev/null +++ b/app/(app)/settings/panels/space-panel.tsx @@ -0,0 +1,244 @@ +'use client' + +import { useState } from 'react' +import { Copy } from 'lucide-react' +import { updateSpaceSettings, updateSpaceVisibility } from '@/lib/actions' +import { financialVisibilities, directoryVisibilities } from '@/lib/validations' +import type { SaveStatus, Space, SpaceExt } from '../types' + +interface Props extends SaveStatus { + space: Space + isAdmin: boolean +} + +export function SpacePanel({ space, isAdmin, saving, setSaving, setMessage }: Props) { + const spaceExt = space as SpaceExt + + // Space form (basic + mission statement) + const [spaceForm, setSpaceForm] = useState({ + name: space.name || '', + slug: space.slug || '', + city: space.city || '', + require_approval: space.require_approval ?? true, + public_member_directory: space.public_member_directory ?? false, + mission_statement: spaceExt.mission_statement || '', + }) + + // Visibility form (governance) — separate server action, separate save. + const [visibilityForm, setVisibilityForm] = useState({ + financial_visibility: (spaceExt.financial_visibility as string | undefined) || 'board_visible', + member_directory_visibility: (spaceExt.member_directory_visibility as string | undefined) || 'members_visible', + }) + + async function handleSaveSpace(e: React.FormEvent) { + e.preventDefault() + if (!isAdmin) return + setSaving(true) + setMessage(null) + const result = await updateSpaceSettings(spaceForm) + if (result.error) { + setMessage({ type: 'error', text: result.error }) + } else { + setMessage({ type: 'success', text: 'Settings saved!' }) + } + setSaving(false) + } + + async function handleSaveVisibility(e: React.FormEvent) { + e.preventDefault() + if (!isAdmin) return + setSaving(true) + setMessage(null) + const result = await updateSpaceVisibility(visibilityForm) + if ('error' in result && result.error) { + setMessage({ type: 'error', text: result.error }) + } else { + setMessage({ type: 'success', text: 'Visibility updated.' }) + } + setSaving(false) + } + + return ( + <> + {/* Space Settings */} +
+

Space Settings

+ +
+
+
+ + setSpaceForm(f => ({ ...f, name: e.target.value }))} + disabled={!isAdmin} + className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary" + /> +
+
+ +
+ hackerspace.sh/ + setSpaceForm(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))} + disabled={!isAdmin} + className="flex-1 bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary" + /> +
+
+
+ +
+ + setSpaceForm(f => ({ ...f, city: e.target.value }))} + placeholder="Mesa, AZ" + disabled={!isAdmin} + className="w-full max-w-md bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground disabled:opacity-50 focus:outline-none focus:border-primary" + /> +
+ +
+
+
+

Require approval to join

+

New members must be approved by an admin

+
+ +
+ +
+
+

Public member directory

+

Anyone can see member list (names only)

+
+ +
+
+ +
+ +