Hostinger Reach integration UI + ARC Buildium matching, drop Mailchimp

- HostingerReachPage (replaces MailchimpPage): connect Reach via
  reach-connection, per-association segment sync via reach-sync
- ARC Applications: Buildium import review/matching updates
- buildium-import-stage/apply: latest staging + apply changes (already
  deployed to Supabase)
- migrations: hostinger_reach_integration + arc_finalized_lock service
  role (already applied to live DB)
- CI: note that deployment is VPS-side polling (auto-deploy.sh cron)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:07:30 -04:00
parent 220892203c
commit abd46bcb2b
15 changed files with 1041 additions and 726 deletions
+2 -2
View File
@@ -123,7 +123,7 @@ import EmailSendersPage from "./pages/EmailSendersPage";
import EmailTemplatesPage from "./pages/EmailTemplatesPage";
import NotifyBoardPage from "./pages/NotifyBoardPage";
import NotifyOwnersPage from "./pages/NotifyOwnersPage";
import MailchimpPage from "./pages/MailchimpPage";
import HostingerReachPage from "./pages/HostingerReachPage";
import DataMigration from "./pages/DataMigration";
import MediaLibraryPage from "./pages/MediaLibraryPage";
import MigrationFieldsPage from "./pages/MigrationFieldsPage";
@@ -419,7 +419,7 @@ const App = () => (
<Route path="email-templates" element={<EmailTemplatesPage />} />
<Route path="notify-board" element={<NotifyBoardPage />} />
<Route path="notify-owners" element={<NotifyOwnersPage />} />
<Route path="mailchimp" element={<MailchimpPage />} />
<Route path="hostinger-reach" element={<HostingerReachPage />} />
<Route path="messages" element={<MessagesPage />} />
{/* Financial module */}
<Route path="vendors" element={<VendorsPage />} />
+1 -1
View File
@@ -50,7 +50,7 @@ export const SETTINGS_PAGES = [
items: [
{ path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 },
{ path: "/dashboard/settings/stripe-accounts", title: "Payment Gateways", icon: CreditCard },
{ path: "/dashboard/mailchimp", title: "Mailchimp", icon: Mail },
{ path: "/dashboard/hostinger-reach", title: "Hostinger Reach", icon: Mail },
],
},
];
+345 -14
View File
@@ -14,6 +14,51 @@ export type Database = {
}
public: {
Tables: {
_coa_perassoc_backup: {
Row: {
account_name: string | null
account_number: string | null
account_type: string | null
accounting_system: string | null
association_id: string | null
association_ids: string[] | null
created_at: string | null
description: string | null
id: string | null
is_active: boolean | null
parent_account_id: string | null
updated_at: string | null
}
Insert: {
account_name?: string | null
account_number?: string | null
account_type?: string | null
accounting_system?: string | null
association_id?: string | null
association_ids?: string[] | null
created_at?: string | null
description?: string | null
id?: string | null
is_active?: boolean | null
parent_account_id?: string | null
updated_at?: string | null
}
Update: {
account_name?: string | null
account_number?: string | null
account_type?: string | null
accounting_system?: string | null
association_id?: string | null
association_ids?: string[] | null
created_at?: string | null
description?: string | null
id?: string | null
is_active?: boolean | null
parent_account_id?: string | null
updated_at?: string | null
}
Relationships: []
}
address_geocodes: {
Row: {
address: string
@@ -133,6 +178,7 @@ export type Database = {
show_on_public_page: boolean
signable_documents: Json
sort_order: number | null
source_rv_lot_id: string | null
updated_at: string
visible_to_roles: Json
}
@@ -152,6 +198,7 @@ export type Database = {
show_on_public_page?: boolean
signable_documents?: Json
sort_order?: number | null
source_rv_lot_id?: string | null
updated_at?: string
visible_to_roles?: Json
}
@@ -171,6 +218,7 @@ export type Database = {
show_on_public_page?: boolean
signable_documents?: Json
sort_order?: number | null
source_rv_lot_id?: string | null
updated_at?: string
visible_to_roles?: Json
}
@@ -724,6 +772,24 @@ export type Database = {
},
]
}
"arc-committee-reviews": {
Row: {
created_at: string
id: number
user_id: string | null
}
Insert: {
created_at?: string
id?: number
user_id?: string | null
}
Update: {
created_at?: string
id?: number
user_id?: string | null
}
Relationships: []
}
association_custom_fields: {
Row: {
association_id: string
@@ -1105,6 +1171,7 @@ export type Database = {
}
associations: {
Row: {
accounting_system: string
address: string | null
attorney_email: string | null
attorney_firm: string | null
@@ -1137,6 +1204,7 @@ export type Database = {
zoho_organization_id: string | null
}
Insert: {
accounting_system?: string
address?: string | null
attorney_email?: string | null
attorney_firm?: string | null
@@ -1169,6 +1237,7 @@ export type Database = {
zoho_organization_id?: string | null
}
Update: {
accounting_system?: string
address?: string | null
attorney_email?: string | null
attorney_firm?: string | null
@@ -1555,6 +1624,8 @@ export type Database = {
created_at: string
created_by: string | null
description: string | null
document_name: string | null
document_url: string | null
expiry_date: string | null
id: string
project_id: string | null
@@ -1569,6 +1640,8 @@ export type Database = {
created_at?: string
created_by?: string | null
description?: string | null
document_name?: string | null
document_url?: string | null
expiry_date?: string | null
id?: string
project_id?: string | null
@@ -1583,6 +1656,8 @@ export type Database = {
created_at?: string
created_by?: string | null
description?: string | null
document_name?: string | null
document_url?: string | null
expiry_date?: string | null
id?: string
project_id?: string | null
@@ -2133,6 +2208,7 @@ export type Database = {
Row: {
approval_authority: boolean
association_id: string
can_upload: boolean
created_at: string
id: string
member_email: string | null
@@ -2145,6 +2221,7 @@ export type Database = {
Insert: {
approval_authority?: boolean
association_id: string
can_upload?: boolean
created_at?: string
id?: string
member_email?: string | null
@@ -2157,6 +2234,7 @@ export type Database = {
Update: {
approval_authority?: boolean
association_id?: string
can_upload?: boolean
created_at?: string
id?: string
member_email?: string | null
@@ -2803,6 +2881,8 @@ export type Database = {
id: string
logo_url: string | null
memo_prefix: string | null
micr_gap_1: number
micr_gap_2: number
offset_x: number
offset_y: number
payer_address: string | null
@@ -2825,6 +2905,8 @@ export type Database = {
id?: string
logo_url?: string | null
memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number
offset_y?: number
payer_address?: string | null
@@ -2847,6 +2929,8 @@ export type Database = {
id?: string
logo_url?: string | null
memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number
offset_y?: number
payer_address?: string | null
@@ -3451,6 +3535,8 @@ export type Database = {
id: string
logo_url: string | null
memo_prefix: string | null
micr_gap_1: number
micr_gap_2: number
offset_x: number
offset_y: number
payer_address: string | null
@@ -3472,6 +3558,8 @@ export type Database = {
id?: string
logo_url?: string | null
memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number
offset_y?: number
payer_address?: string | null
@@ -3493,6 +3581,8 @@ export type Database = {
id?: string
logo_url?: string | null
memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number
offset_y?: number
payer_address?: string | null
@@ -4352,12 +4442,18 @@ export type Database = {
email_headers: Json | null
feature_type: string | null
id: string
last_open_ip: string | null
last_open_user_agent: string | null
last_opened_at: string | null
open_count: number
opened_at: string | null
proof_url: string | null
recipient_email: string | null
sender_email: string | null
sent_at: string | null
status: string | null
subject: string | null
tracking_id: string | null
user_id: string
}
Insert: {
@@ -4366,12 +4462,18 @@ export type Database = {
email_headers?: Json | null
feature_type?: string | null
id?: string
last_open_ip?: string | null
last_open_user_agent?: string | null
last_opened_at?: string | null
open_count?: number
opened_at?: string | null
proof_url?: string | null
recipient_email?: string | null
sender_email?: string | null
sent_at?: string | null
status?: string | null
subject?: string | null
tracking_id?: string | null
user_id: string
}
Update: {
@@ -4380,12 +4482,18 @@ export type Database = {
email_headers?: Json | null
feature_type?: string | null
id?: string
last_open_ip?: string | null
last_open_user_agent?: string | null
last_opened_at?: string | null
open_count?: number
opened_at?: string | null
proof_url?: string | null
recipient_email?: string | null
sender_email?: string | null
sent_at?: string | null
status?: string | null
subject?: string | null
tracking_id?: string | null
user_id?: string
}
Relationships: []
@@ -5203,6 +5311,77 @@ export type Database = {
},
]
}
hostinger_reach_config: {
Row: {
api_token: string
created_at: string
created_by: string | null
id: string
updated_at: string
}
Insert: {
api_token: string
created_at?: string
created_by?: string | null
id?: string
updated_at?: string
}
Update: {
api_token?: string
created_at?: string
created_by?: string | null
id?: string
updated_at?: string
}
Relationships: []
}
hostinger_reach_segments: {
Row: {
association_id: string
created_at: string
id: string
last_sync_at: string | null
last_sync_count: number | null
last_sync_error: string | null
last_sync_status: string | null
segment_name: string | null
segment_uuid: string | null
updated_at: string
}
Insert: {
association_id: string
created_at?: string
id?: string
last_sync_at?: string | null
last_sync_count?: number | null
last_sync_error?: string | null
last_sync_status?: string | null
segment_name?: string | null
segment_uuid?: string | null
updated_at?: string
}
Update: {
association_id?: string
created_at?: string
id?: string
last_sync_at?: string | null
last_sync_count?: number | null
last_sync_error?: string | null
last_sync_status?: string | null
segment_name?: string | null
segment_uuid?: string | null
updated_at?: string
}
Relationships: [
{
foreignKeyName: "hostinger_reach_segments_association_id_fkey"
columns: ["association_id"]
isOneToOne: true
referencedRelation: "associations"
referencedColumns: ["id"]
},
]
}
in_app_notifications: {
Row: {
created_at: string
@@ -7173,12 +7352,42 @@ export type Database = {
}
Relationships: []
}
rv_boat_lot_maps: {
Row: {
association_id: string
config: Json
updated_at: string | null
}
Insert: {
association_id: string
config?: Json
updated_at?: string | null
}
Update: {
association_id?: string
config?: Json
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "rv_boat_lot_maps_association_id_fkey"
columns: ["association_id"]
isOneToOne: true
referencedRelation: "associations"
referencedColumns: ["id"]
},
]
}
rv_boat_lot_rentals: {
Row: {
association_id: string
created_at: string
end_date: string | null
id: string
insurance_carrier: string | null
insurance_document_url: string | null
insurance_expiration_date: string | null
insurance_policy_number: string | null
is_owner: boolean
lot_id: string
monthly_rate: number | null
@@ -7199,6 +7408,10 @@ export type Database = {
created_at?: string
end_date?: string | null
id?: string
insurance_carrier?: string | null
insurance_document_url?: string | null
insurance_expiration_date?: string | null
insurance_policy_number?: string | null
is_owner?: boolean
lot_id: string
monthly_rate?: number | null
@@ -7219,6 +7432,10 @@ export type Database = {
created_at?: string
end_date?: string | null
id?: string
insurance_carrier?: string | null
insurance_document_url?: string | null
insurance_expiration_date?: string | null
insurance_policy_number?: string | null
is_owner?: boolean
lot_id?: string
monthly_rate?: number | null
@@ -7278,6 +7495,7 @@ export type Database = {
owner_id: string | null
position: number
requested_lot_type: string | null
requested_size: string | null
requester_email: string | null
requester_name: string
requester_phone: string | null
@@ -7300,6 +7518,7 @@ export type Database = {
owner_id?: string | null
position: number
requested_lot_type?: string | null
requested_size?: string | null
requester_email?: string | null
requester_name: string
requester_phone?: string | null
@@ -7322,6 +7541,7 @@ export type Database = {
owner_id?: string | null
position?: number
requested_lot_type?: string | null
requested_size?: string | null
requester_email?: string | null
requester_name?: string
requester_phone?: string | null
@@ -7480,6 +7700,50 @@ export type Database = {
},
]
}
rv_renter_insurance_requests: {
Row: {
created_at: string | null
created_by: string | null
expires_at: string | null
id: string
rental_id: string
sent_at: string | null
sent_to_email: string | null
submitted_at: string | null
token: string
}
Insert: {
created_at?: string | null
created_by?: string | null
expires_at?: string | null
id?: string
rental_id: string
sent_at?: string | null
sent_to_email?: string | null
submitted_at?: string | null
token?: string
}
Update: {
created_at?: string | null
created_by?: string | null
expires_at?: string | null
id?: string
rental_id?: string
sent_at?: string | null
sent_to_email?: string | null
submitted_at?: string | null
token?: string
}
Relationships: [
{
foreignKeyName: "rv_renter_insurance_requests_rental_id_fkey"
columns: ["rental_id"]
isOneToOne: false
referencedRelation: "rv_boat_lot_rentals"
referencedColumns: ["id"]
},
]
}
saved_form_templates: {
Row: {
association_id: string | null
@@ -9001,6 +9265,7 @@ export type Database = {
association_id: string
bathrooms: number | null
bedrooms: number | null
buildium_account_number: string | null
buildium_unit_id: string | null
city: string | null
created_at: string
@@ -9028,6 +9293,7 @@ export type Database = {
association_id: string
bathrooms?: number | null
bedrooms?: number | null
buildium_account_number?: string | null
buildium_unit_id?: string | null
city?: string | null
created_at?: string
@@ -9055,6 +9321,7 @@ export type Database = {
association_id?: string
bathrooms?: number | null
bedrooms?: number | null
buildium_account_number?: string | null
buildium_unit_id?: string | null
city?: string | null
created_at?: string
@@ -9125,7 +9392,7 @@ export type Database = {
}
Insert: {
id?: string
role: Database["public"]["Enums"]["app_role"]
role?: Database["public"]["Enums"]["app_role"]
user_id: string
}
Update: {
@@ -9356,6 +9623,7 @@ export type Database = {
phone: string | null
profile_last_submitted_at: string | null
remittance_address: string | null
share_with_board: boolean
tax_id: string | null
updated_at: string
w9_document_url: string | null
@@ -9390,6 +9658,7 @@ export type Database = {
phone?: string | null
profile_last_submitted_at?: string | null
remittance_address?: string | null
share_with_board?: boolean
tax_id?: string | null
updated_at?: string
w9_document_url?: string | null
@@ -9424,6 +9693,7 @@ export type Database = {
phone?: string | null
profile_last_submitted_at?: string | null
remittance_address?: string | null
share_with_board?: boolean
tax_id?: string | null
updated_at?: string
w9_document_url?: string | null
@@ -9503,9 +9773,12 @@ export type Database = {
category: string
citation: string | null
created_at: string | null
cure_days: number | null
description: string | null
id: string
letter_templates: string | null
requested_action: string | null
stage_count: number | null
updated_at: string | null
}
Insert: {
@@ -9514,9 +9787,12 @@ export type Database = {
category: string
citation?: string | null
created_at?: string | null
cure_days?: number | null
description?: string | null
id?: string
letter_templates?: string | null
requested_action?: string | null
stage_count?: number | null
updated_at?: string | null
}
Update: {
@@ -9525,9 +9801,12 @@ export type Database = {
category?: string
citation?: string | null
created_at?: string | null
cure_days?: number | null
description?: string | null
id?: string
letter_templates?: string | null
requested_action?: string | null
stage_count?: number | null
updated_at?: string | null
}
Relationships: [
@@ -9546,6 +9825,7 @@ export type Database = {
assigned_to: string | null
association_id: string
category: string | null
certified_mail: string | null
created_at: string
created_by: string | null
description: string | null
@@ -9575,6 +9855,7 @@ export type Database = {
assigned_to?: string | null
association_id: string
category?: string | null
certified_mail?: string | null
created_at?: string
created_by?: string | null
description?: string | null
@@ -9604,6 +9885,7 @@ export type Database = {
assigned_to?: string | null
association_id?: string
category?: string | null
certified_mail?: string | null
created_at?: string
created_by?: string | null
description?: string | null
@@ -9982,6 +10264,8 @@ export type Database = {
}
Functions: {
_get_vendor_ach_key: { Args: never; Returns: string }
avria_norm_addr: { Args: { p: string }; Returns: string }
avria_normalize_key: { Args: { input: string }; Returns: string }
calculate_legal_matter_ledger_amount: {
Args: { p_legal_matter_id: string }
Returns: number
@@ -10016,6 +10300,10 @@ export type Database = {
Args: { payload: Json; queue_name: string }
Returns: number
}
ensure_accounting_vendor: {
Args: { _association_id: string; _public_vendor_id: string }
Returns: string
}
get_association_accounting_system: {
Args: { _association_id: string }
Returns: string
@@ -10037,7 +10325,6 @@ export type Database = {
Args: { _user_id?: string }
Returns: string[]
}
get_maintenance_status: { Args: never; Returns: Json }
get_master_board_association_ids: {
Args: { _user_id?: string }
Returns: string[]
@@ -10149,6 +10436,7 @@ export type Database = {
}
Returns: boolean
}
is_management_staff: { Args: { _user_id?: string }; Returns: boolean }
is_master_board_member_of_association: {
Args: { _association_id: string; _user_id: string }
Returns: boolean
@@ -10193,6 +10481,16 @@ export type Database = {
}[]
}
lookup_email_by_username: { Args: { _username: string }; Returns: string }
lookup_rv_renter_insurance_request: {
Args: { p_token: string }
Returns: {
expires_at: string
rental_id: string
renter_name: string
request_id: string
submitted_at: string
}[]
}
lookup_signup_code: {
Args: { p_code: string }
Returns: {
@@ -10305,6 +10603,13 @@ export type Database = {
}
Returns: number
}
postgres_fdw_disconnect: { Args: { "": string }; Returns: boolean }
postgres_fdw_disconnect_all: { Args: never; Returns: boolean }
postgres_fdw_get_connections: {
Args: never
Returns: Record<string, unknown>[]
}
postgres_fdw_handler: { Args: never; Returns: unknown }
read_email_batch: {
Args: { batch_size: number; queue_name: string; vt: number }
Returns: {
@@ -10322,10 +10627,30 @@ export type Database = {
Args: { p_option: string; p_token: string }
Returns: Json
}
record_email_open:
| { Args: { _tracking_id: string }; Returns: boolean }
| {
Args: {
p_ip?: string
p_tracking_id: string
p_user_agent?: string
}
Returns: undefined
}
refresh_committee_member_active_status: {
Args: never
Returns: undefined
}
submit_rv_renter_insurance: {
Args: {
p_carrier: string
p_document_url?: string
p_expiration_date: string
p_policy_number: string
p_token: string
}
Returns: boolean
}
submit_tenant_info: {
Args: {
p_email?: string
@@ -10393,18 +10718,21 @@ export type Database = {
| "admin"
| "manager"
| "homeowner"
| "staff"
| "employee"
| "board_member"
| "arc_member"
| "fining_member"
| "association_management"
| "legal"
| "rv_boat_lot"
| "fining_member"
| "management"
| "staff"
| "employee"
| "master_board_member"
| "rv_renter"
| "rv_owner"
| "rv_boat_lot"
| "management"
| "association_management"
budget_entry_type: "account" | "customer_project"
budget_period_type: "monthly" | "quarterly" | "annually"
budget_status: "draft" | "active"
fee_exclusion_mode: "waive" | "override_amount" | "override_percent"
fee_exclusion_type: "late_fee" | "interest"
}
@@ -10538,19 +10866,22 @@ export const Constants = {
"admin",
"manager",
"homeowner",
"staff",
"employee",
"board_member",
"arc_member",
"fining_member",
"association_management",
"legal",
"rv_boat_lot",
"fining_member",
"management",
"staff",
"employee",
"master_board_member",
"rv_renter",
"rv_owner",
"rv_boat_lot",
"management",
"association_management",
],
budget_entry_type: ["account", "customer_project"],
budget_period_type: ["monthly", "quarterly", "annually"],
budget_status: ["draft", "active"],
fee_exclusion_mode: ["waive", "override_amount", "override_percent"],
fee_exclusion_type: ["late_fee", "interest"],
},
+57 -1
View File
@@ -19,8 +19,12 @@ import { useDropzone } from "react-dropzone";
import {
Plus, Search, Gavel, Filter, FileText, Calendar, Building2, User,
Home, Clock, ChevronRight, CheckCircle2, XCircle, AlertCircle,
MessageSquare, Image, Paperclip, RefreshCw, Download, UploadCloud, X, File,
MessageSquare, Image, Paperclip, RefreshCw, Download, UploadCloud, X, File, Trash2,
} from "lucide-react";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { Tables } from "@/integrations/supabase/types";
import { format } from "date-fns";
import VotingAndComments from "@/components/shared/VotingAndComments";
@@ -364,6 +368,29 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
}
};
/* ── Delete ─── */
const handleDelete = async (app: AppRow) => {
try {
// Clean up generic vote/comment rows (keyed by entity, no FK cascade)
await supabase.from("entity_votes").delete().eq("entity_type", "arc_application").eq("entity_id", app.id);
await supabase.from("entity_comments").delete().eq("entity_type", "arc_application").eq("entity_id", app.id);
// Remove any attachments from storage
const prefix = `${app.association_id}/${app.id}`;
const { data: storedFiles } = await supabase.storage.from("arc-files").list(prefix, { limit: 200 });
if (storedFiles && storedFiles.length > 0) {
await supabase.storage.from("arc-files").remove(storedFiles.map((f) => `${prefix}/${f.name}`));
}
// arc_application_comments / arc_application_votes cascade via FK; inbound emails are SET NULL
const { error } = await supabase.from("arc_applications").delete().eq("id", app.id);
if (error) throw error;
toast({ title: "Application deleted" });
setSelectedApp((prev) => (prev && prev.id === app.id ? null : prev));
fetchData();
} catch (err: any) {
toast({ title: "Error", description: err.message, variant: "destructive" });
}
};
const ownerName = (app: AppRow) =>
app.owners ? `${app.owners.first_name} ${app.owners.last_name}` : "—";
@@ -778,6 +805,7 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
<ARCDetailPanel
app={selectedApp}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
onClose={() => setSelectedApp(null)}
onRefresh={fetchData}
/>
@@ -795,11 +823,13 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
function ARCDetailPanel({
app,
onStatusChange,
onDelete,
onClose,
onRefresh,
}: {
app: AppRow;
onStatusChange: (id: string, status: string, notes?: string) => void;
onDelete: (app: AppRow) => void;
onClose: () => void;
onRefresh: () => void;
}) {
@@ -924,6 +954,32 @@ function ARCDetailPanel({
>
<Download className="h-3 w-3" /> Export Record
</Button>
{isAdmin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="h-7 text-[11px] gap-1 text-destructive hover:text-destructive">
<Trash2 className="h-3 w-3" /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this ARC application?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes {app.title} along with its votes, comments, and attached files. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => onDelete(app)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<span className="text-[11px] text-muted-foreground">
{app.submitted_date ? format(new Date(app.submitted_date), "MMMM d, yyyy") : "—"}
</span>
+15 -126
View File
@@ -41,10 +41,6 @@ export default function ComposeEmailPage() {
const [sending, setSending] = useState(false);
const [tab, setTab] = useState("content");
const [pickerOpen, setPickerOpen] = useState<null | "to" | "cc" | "bcc">(null);
const [sendMethod, setSendMethod] = useState<"smtp" | "mailchimp">("smtp");
const [mailchimpConfig, setMailchimpConfig] = useState<any>(null);
const [mcFromName, setMcFromName] = useState("");
const [mcReplyTo, setMcReplyTo] = useState("");
const [signatures, setSignatures] = useState<EmailSignature[]>([]);
const [signatureId, setSignatureId] = useState<string>("none");
const [signatureHtml, setSignatureHtml] = useState<string>("");
@@ -87,23 +83,6 @@ export default function ComposeEmailPage() {
if (name) setSubject(`Message for ${name}`);
}, [searchParams]);
useEffect(() => {
const fetchMc = async () => {
if (!selectedAssociation?.id) { setMailchimpConfig(null); return; }
const { data } = await supabase
.from("mailchimp_configs")
.select("*")
.eq("association_id", selectedAssociation.id)
.maybeSingle();
setMailchimpConfig(data || null);
if (data) {
setMcFromName((prev) => prev || selectedAssociation?.name || "");
setMcReplyTo((prev) => prev || user?.email || "");
}
};
fetchMc();
}, [selectedAssociation?.id, user?.email]);
const fetchSenders = async () => {
const { data: result } = await supabase.functions.invoke("send-smtp-email", {
body: { action: "list_senders" },
@@ -170,53 +149,7 @@ export default function ComposeEmailPage() {
if (e.target.files) setAttachments(Array.from(e.target.files));
};
const handleSendMailchimp = async (sendNow: boolean) => {
if (!selectedAssociation?.id) {
toast({ variant: "destructive", title: "No association", description: "Select an association first." });
return;
}
if (!mailchimpConfig?.audience_id) {
toast({ variant: "destructive", title: "Mailchimp not configured", description: "Set up Mailchimp + audience under Mailchimp settings." });
return;
}
if (!subject.trim() || !content.trim() || !mcFromName.trim() || !mcReplyTo.trim()) {
toast({ variant: "destructive", title: "Missing fields", description: "Subject, content, From name, and Reply-to are required." });
return;
}
setSending(true);
try {
const { data, error } = await supabase.functions.invoke("mailchimp-campaign", {
body: {
association_id: selectedAssociation.id,
subject,
from_name: mcFromName,
reply_to: mcReplyTo,
html: composeBodyWithSignature(content),
send_now: sendNow,
},
});
if (error) throw error;
if (!data?.success) throw new Error(data?.error || "Mailchimp send failed");
toast({
title: sendNow ? "Campaign Sent" : "Draft Saved",
description: sendNow
? `Sent to your Mailchimp audience.`
: `Draft saved in Mailchimp (campaign ${data.campaign_id}).`,
});
if (sendNow) {
setSubject(""); setContent(""); setTemplateId("none");
}
} catch (err: any) {
toast({ variant: "destructive", title: "Mailchimp Error", description: err.message });
} finally {
setSending(false);
}
};
const handleSend = async () => {
if (sendMethod === "mailchimp") {
return handleSendMailchimp(true);
}
if (!senderId || !recipients.trim() || !subject.trim()) {
toast({ variant: "destructive", title: "Validation Error", description: "Sender, recipients, and subject are required." });
return;
@@ -331,58 +264,20 @@ export default function ComposeEmailPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader><CardTitle>Send Method</CardTitle></CardHeader>
<CardContent className="space-y-2">
<Select value={sendMethod} onValueChange={(v) => setSendMethod(v as "smtp" | "mailchimp")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="smtp">SMTP (direct)</SelectItem>
<SelectItem value="mailchimp" disabled={!mailchimpConfig?.audience_id}>
Mailchimp Campaign {mailchimpConfig?.audience_id ? "" : "(not configured)"}
</SelectItem>
</SelectContent>
</Select>
{sendMethod === "mailchimp" && !mailchimpConfig?.audience_id && (
<p className="text-xs text-muted-foreground">
Configure Mailchimp and select an audience under the Mailchimp page first.
</p>
)}
{sendMethod === "mailchimp" && mailchimpConfig?.audience_id && (
<p className="text-xs text-muted-foreground">
Will send to your Mailchimp audience for <strong>{selectedAssociation?.name}</strong>. To/Cc/Bcc and SMTP sender are ignored.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>{sendMethod === "mailchimp" ? "Campaign Details" : "Sender & Recipients"}</CardTitle></CardHeader>
<CardHeader><CardTitle>Sender & Recipients</CardTitle></CardHeader>
<CardContent className="space-y-4">
{sendMethod === "smtp" ? (
<div className="space-y-2">
<Label>From <span className="text-destructive">*</span></Label>
<Select value={senderId} onValueChange={setSenderId}>
<SelectTrigger><SelectValue placeholder="Select Sender" /></SelectTrigger>
<SelectContent>
{senders.map((s: any) => (
<SelectItem key={s.id} value={s.id}>{s.sender_name} ({s.email_address})</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<>
<div className="space-y-2">
<Label>From Name <span className="text-destructive">*</span></Label>
<Input value={mcFromName} onChange={(e) => setMcFromName(e.target.value)} placeholder="Association Name" />
</div>
<div className="space-y-2">
<Label>Reply-to Email <span className="text-destructive">*</span></Label>
<Input type="email" value={mcReplyTo} onChange={(e) => setMcReplyTo(e.target.value)} placeholder="reply@example.com" />
</div>
</>
)}
{sendMethod === "smtp" && (
<div className="space-y-2">
<Label>From <span className="text-destructive">*</span></Label>
<Select value={senderId} onValueChange={setSenderId}>
<SelectTrigger><SelectValue placeholder="Select Sender" /></SelectTrigger>
<SelectContent>
{senders.map((s: any) => (
<SelectItem key={s.id} value={s.id}>{s.sender_name} ({s.email_address})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(
<>
<div className="space-y-2">
<div className="flex items-center justify-between flex-wrap gap-2">
@@ -543,15 +438,9 @@ export default function ComposeEmailPage() {
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => { setRecipients(""); setCc(""); setBcc(""); setShowCc(false); setShowBcc(false); setSubject(""); setContent(""); setAttachments([]); setTemplateId("none"); }}>Cancel</Button>
{sendMethod === "mailchimp" && (
<Button variant="secondary" onClick={() => handleSendMailchimp(false)} disabled={sending || uploading}>
{sending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Mail className="h-4 w-4 mr-2" />}
Save as Draft in Mailchimp
</Button>
)}
<Button onClick={handleSend} disabled={sending || uploading}>
{sending || uploading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : (sendMethod === "mailchimp" ? <Mail className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />)}
{sending || uploading ? "Sending..." : (sendMethod === "mailchimp" ? "Send via Mailchimp" : "Send Email")}
{sending || uploading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Send className="h-4 w-4 mr-2" />}
{sending || uploading ? "Sending..." : "Send Email"}
</Button>
</div>
+266
View File
@@ -0,0 +1,266 @@
import React, { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Loader2, Mail, RefreshCw, CheckCircle2, AlertCircle, Plug, Send, ExternalLink, Users } from "lucide-react";
import { format } from "date-fns";
interface Association { id: string; name: string; }
interface SegmentRow {
association_id: string;
segment_uuid: string | null;
segment_name: string | null;
last_sync_at: string | null;
last_sync_status: string | null;
last_sync_count: number | null;
last_sync_error: string | null;
}
const REACH_DASHBOARD_URL = "https://hpanel.hostinger.com/reach";
export default function HostingerReachPage() {
const { toast } = useToast();
const [associations, setAssociations] = useState<Association[]>([]);
const [selectedAssoc, setSelectedAssoc] = useState<string>("");
// Global token config
const [configId, setConfigId] = useState<string | null>(null);
const [apiToken, setApiToken] = useState("");
const [testing, setTesting] = useState(false);
const [savingToken, setSavingToken] = useState(false);
// Per-association sync state
const [ownerCount, setOwnerCount] = useState<number>(0);
const [segment, setSegment] = useState<SegmentRow | null>(null);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
(async () => {
const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
setAssociations(data || []);
if (data && data.length > 0) setSelectedAssoc(data[0].id);
const { data: cfg } = await supabase
.from("hostinger_reach_config").select("id").order("updated_at", { ascending: false }).limit(1).maybeSingle();
setConfigId(cfg?.id ?? null);
})();
}, []);
useEffect(() => {
if (!selectedAssoc) return;
loadOwnerCount(selectedAssoc);
loadSegment(selectedAssoc);
}, [selectedAssoc]);
const loadOwnerCount = async (id: string) => {
const { count } = await supabase.from("owners").select("id", { count: "exact", head: true })
.eq("association_id", id).eq("status", "active").not("email", "is", null);
setOwnerCount(count || 0);
};
const loadSegment = async (id: string) => {
const { data } = await supabase.from("hostinger_reach_segments").select("*").eq("association_id", id).maybeSingle();
setSegment((data as SegmentRow) ?? null);
};
const handleTest = async () => {
setTesting(true);
try {
const { data, error } = await supabase.functions.invoke("reach-connection", {
body: apiToken.trim() ? { api_token: apiToken.trim() } : {},
});
if (error) throw error;
if (!data.success) throw new Error(data.error || "Connection failed");
toast({ title: "Connected", description: `Reach reachable — ${data.segment_count} segment(s).` });
} catch (e: any) {
toast({ variant: "destructive", title: "Connection failed", description: e.message });
} finally {
setTesting(false);
}
};
const handleSaveToken = async () => {
if (!apiToken.trim()) {
toast({ variant: "destructive", title: "Missing token", description: "Paste your Hostinger Reach API token." });
return;
}
setSavingToken(true);
try {
const { data: { user } } = await supabase.auth.getUser();
const payload = { api_token: apiToken.trim(), created_by: user?.id };
const { error } = configId
? await supabase.from("hostinger_reach_config").update(payload).eq("id", configId)
: await supabase.from("hostinger_reach_config").insert(payload);
if (error) throw error;
const { data: cfg } = await supabase
.from("hostinger_reach_config").select("id").order("updated_at", { ascending: false }).limit(1).maybeSingle();
setConfigId(cfg?.id ?? null);
setApiToken("");
toast({ title: "Saved", description: "Hostinger Reach API token stored." });
} catch (e: any) {
toast({ variant: "destructive", title: "Save failed", description: e.message });
} finally {
setSavingToken(false);
}
};
const handleSync = async () => {
if (!configId) {
toast({ variant: "destructive", title: "Not connected", description: "Save your Reach API token first." });
return;
}
setSyncing(true);
try {
const { data, error } = await supabase.functions.invoke("reach-sync", { body: { association_id: selectedAssoc } });
if (error) throw error;
if (!data.success) throw new Error(data.error || "Sync failed");
toast({
title: "Sync complete",
description: `${data.succeeded} synced${data.failed > 0 ? `, ${data.failed} failed` : ""} → segment "${data.segment_name}".`,
});
loadSegment(selectedAssoc);
} catch (e: any) {
toast({ variant: "destructive", title: "Sync failed", description: e.message });
} finally {
setSyncing(false);
}
};
const assocName = associations.find((a) => a.id === selectedAssoc)?.name;
return (
<div className="container mx-auto p-6 max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Mail className="w-7 h-7 text-primary" />
<div>
<h1 className="text-2xl font-bold text-foreground">Hostinger Reach</h1>
<p className="text-sm text-muted-foreground">
Push each association's owners into Hostinger Reach as contacts, organized into a per-association segment. Compose and send campaigns from the Reach dashboard.
</p>
</div>
</div>
{/* Connection */}
<Card>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2"><Plug className="w-5 h-5" /> API Connection</CardTitle>
<CardDescription>
One account-wide token. Create it in hPanel → Reach → API. It's stored securely and never shown again after saving.
</CardDescription>
</div>
{configId && (
<Badge variant="outline" className="bg-green-500/10 text-green-700 border-green-500/30">
<CheckCircle2 className="w-3 h-3 mr-1" /> Connected
</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Hostinger Reach API Token</Label>
<Input
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder={configId ? "•••••••• (saved) — paste a new token to replace" : "Paste your API token"}
/>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" onClick={handleTest} disabled={testing}>
{testing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Test Connection
</Button>
<Button onClick={handleSaveToken} disabled={savingToken || !apiToken.trim()}>
{savingToken ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{configId ? "Replace Token" : "Save Token"}
</Button>
</div>
</CardContent>
</Card>
{/* Sync */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Users className="w-5 h-5" /> Sync Owners to Reach</CardTitle>
<CardDescription>Each association's active owners (with email) are pushed as contacts and grouped into their own Reach segment.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Association</Label>
<Select value={selectedAssoc} onValueChange={setSelectedAssoc}>
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
<SelectContent>
{associations.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">
<span className="font-medium text-foreground">{ownerCount}</span> active owner(s) with email
{segment?.segment_name && <> · segment: <span className="font-medium">{segment.segment_name}</span></>}
</p>
<p className="text-sm text-muted-foreground">Re-running is safe — existing contacts aren't duplicated.</p>
</div>
<Button onClick={handleSync} disabled={syncing || !configId}>
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Sync Now
</Button>
</div>
{segment?.last_sync_at && (
<>
<Separator />
<div className="space-y-1 text-sm">
<p className="text-muted-foreground">Last sync</p>
<p>{format(new Date(segment.last_sync_at), "PPpp")}</p>
<div className="flex items-center gap-2 mt-2">
{segment.last_sync_status === "success" && (
<Badge className="bg-green-500/15 text-green-700 border-green-500/30" variant="outline">
<CheckCircle2 className="w-3 h-3 mr-1" /> Success
</Badge>
)}
{segment.last_sync_status === "partial" && (
<Badge className="bg-yellow-500/15 text-yellow-700 border-yellow-500/30" variant="outline">
<AlertCircle className="w-3 h-3 mr-1" /> Partial
</Badge>
)}
{segment.last_sync_status === "failed" && (
<Badge variant="destructive"><AlertCircle className="w-3 h-3 mr-1" /> Failed</Badge>
)}
<span className="text-muted-foreground">{segment.last_sync_count ?? 0} synced</span>
</div>
{segment.last_sync_error && (
<p className="text-xs text-destructive mt-2 break-all">{segment.last_sync_error}</p>
)}
</div>
</>
)}
</CardContent>
</Card>
{/* Campaigns live in Reach */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-muted-foreground"><Send className="w-5 h-5" /> Send Campaigns in Reach</CardTitle>
<CardDescription>
Hostinger Reach campaigns are composed and sent from the Reach dashboard. Sync owners here, then target the
{assocName ? <> <span className="font-medium text-foreground">{assocName}</span></> : " association"} segment in Reach.
</CardDescription>
</CardHeader>
<CardContent>
<a href={REACH_DASHBOARD_URL} target="_blank" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1 text-sm">
Open Hostinger Reach <ExternalLink className="w-3 h-3" />
</a>
</CardContent>
</Card>
</div>
);
}
-530
View File
@@ -1,530 +0,0 @@
import React, { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Loader2, Mail, RefreshCw, CheckCircle2, AlertCircle, Plus, Send, ExternalLink } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { format } from "date-fns";
interface Association { id: string; name: string; }
interface MailchimpConfig {
id: string;
association_id: string;
api_key: string;
server_prefix: string;
audience_id: string | null;
audience_name: string | null;
last_sync_at: string | null;
last_sync_status: string | null;
last_sync_count: number | null;
last_sync_error: string | null;
}
interface Audience { id: string; name: string; stats?: { member_count?: number } }
export default function MailchimpPage() {
const { toast } = useToast();
const [associations, setAssociations] = useState<Association[]>([]);
const [selectedAssoc, setSelectedAssoc] = useState<string>("");
const [config, setConfig] = useState<MailchimpConfig | null>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
// Form state
const [apiKey, setApiKey] = useState("");
const [serverPrefix, setServerPrefix] = useState("");
const [audiences, setAudiences] = useState<Audience[]>([]);
const [selectedAudience, setSelectedAudience] = useState<string>("");
const [newAudienceName, setNewAudienceName] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [ownerCount, setOwnerCount] = useState<number>(0);
// Campaign state
const [campaignSubject, setCampaignSubject] = useState("");
const [campaignFromName, setCampaignFromName] = useState("");
const [campaignReplyTo, setCampaignReplyTo] = useState("");
const [campaignHtml, setCampaignHtml] = useState("");
const [sendingCampaign, setSendingCampaign] = useState(false);
const [lastCampaign, setLastCampaign] = useState<{ web_id?: number; sent: boolean } | null>(null);
useEffect(() => {
(async () => {
const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
setAssociations(data || []);
if (data && data.length > 0) setSelectedAssoc(data[0].id);
})();
}, []);
useEffect(() => {
if (!selectedAssoc) return;
loadConfig(selectedAssoc);
loadOwnerCount(selectedAssoc);
}, [selectedAssoc]);
const loadOwnerCount = async (id: string) => {
const { count } = await supabase.from("owners").select("id", { count: "exact", head: true })
.eq("association_id", id).eq("status", "active").not("email", "is", null);
setOwnerCount(count || 0);
};
const loadConfig = async (id: string) => {
setLoading(true);
setConfig(null);
setApiKey(""); setServerPrefix(""); setAudiences([]); setSelectedAudience(""); setShowCreate(false);
try {
const { data, error } = await supabase.from("mailchimp_configs").select("*").eq("association_id", id).maybeSingle();
if (error) throw error;
if (data) {
setConfig(data as any);
setApiKey(data.api_key);
setServerPrefix(data.server_prefix);
setSelectedAudience(data.audience_id || "");
}
} catch (e: any) {
toast({ variant: "destructive", title: "Load error", description: e.message });
} finally {
setLoading(false);
}
};
const detectPrefix = (key: string) => {
const parts = key.split("-");
return parts.length > 1 ? parts[parts.length - 1] : "";
};
const handleApiKeyChange = (val: string) => {
setApiKey(val);
if (!serverPrefix) {
const detected = detectPrefix(val.trim());
if (detected) setServerPrefix(detected);
}
};
const handleTestAndLoad = async () => {
if (!apiKey || !serverPrefix) {
toast({ variant: "destructive", title: "Missing info", description: "Enter API key and server prefix." });
return;
}
setLoading(true);
try {
const { data, error } = await supabase.functions.invoke("mailchimp-audiences", {
body: { action: "list", api_key: apiKey, server_prefix: serverPrefix },
});
if (error) throw error;
if (!data.success) throw new Error(data.error || "Failed to connect");
setAudiences(data.lists || []);
toast({ title: "Connected", description: `Found ${data.lists?.length || 0} audience(s).` });
} catch (e: any) {
toast({ variant: "destructive", title: "Mailchimp connection failed", description: e.message });
} finally {
setLoading(false);
}
};
const handleCreateAudience = async () => {
const assoc = associations.find(a => a.id === selectedAssoc);
const name = newAudienceName.trim() || assoc?.name || "HOA Owners";
setLoading(true);
try {
const { data, error } = await supabase.functions.invoke("mailchimp-audiences", {
body: {
action: "create",
api_key: apiKey,
server_prefix: serverPrefix,
audience_name: name,
association_name: assoc?.name,
},
});
if (error) throw error;
if (!data.success) throw new Error(data.error || "Create failed");
toast({ title: "Audience created", description: data.list?.name });
setSelectedAudience(data.list.id);
await handleTestAndLoad();
setShowCreate(false);
setNewAudienceName("");
} catch (e: any) {
toast({ variant: "destructive", title: "Create failed", description: e.message });
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!selectedAssoc || !apiKey || !serverPrefix) {
toast({ variant: "destructive", title: "Missing fields", description: "API key, server prefix and audience are required." });
return;
}
setLoading(true);
try {
const audience = audiences.find(a => a.id === selectedAudience);
const { data: { user } } = await supabase.auth.getUser();
const payload = {
association_id: selectedAssoc,
api_key: apiKey,
server_prefix: serverPrefix,
audience_id: selectedAudience || null,
audience_name: audience?.name || null,
created_by: user?.id,
};
const { error } = config
? await supabase.from("mailchimp_configs").update(payload).eq("id", config.id)
: await supabase.from("mailchimp_configs").insert(payload);
if (error) throw error;
toast({ title: "Saved", description: "Mailchimp configuration updated." });
loadConfig(selectedAssoc);
} catch (e: any) {
toast({ variant: "destructive", title: "Save failed", description: e.message });
} finally {
setLoading(false);
}
};
const handleSync = async () => {
if (!config?.audience_id) {
toast({ variant: "destructive", title: "Not configured", description: "Save Mailchimp config with an audience first." });
return;
}
setSyncing(true);
try {
const { data, error } = await supabase.functions.invoke("mailchimp-sync", {
body: { association_id: selectedAssoc },
});
if (error) throw error;
if (!data.success) throw new Error(data.error || "Sync failed");
toast({
title: "Sync complete",
description: `${data.succeeded} synced${data.failed > 0 ? `, ${data.failed} failed` : ""}.`,
});
loadConfig(selectedAssoc);
} catch (e: any) {
toast({ variant: "destructive", title: "Sync failed", description: e.message });
} finally {
setSyncing(false);
}
};
const handleDisconnect = async () => {
if (!config) return;
if (!confirm("Disconnect Mailchimp for this association? Owners already in Mailchimp won't be removed.")) return;
setLoading(true);
try {
const { error } = await supabase.from("mailchimp_configs").delete().eq("id", config.id);
if (error) throw error;
toast({ title: "Disconnected" });
loadConfig(selectedAssoc);
} catch (e: any) {
toast({ variant: "destructive", title: "Failed", description: e.message });
} finally {
setLoading(false);
}
};
const handleSendCampaign = async (sendNow: boolean) => {
if (!campaignSubject || !campaignFromName || !campaignReplyTo || !campaignHtml) {
toast({ variant: "destructive", title: "Missing fields", description: "Subject, from name, reply-to and content are required." });
return;
}
setSendingCampaign(true);
setLastCampaign(null);
try {
const { data, error } = await supabase.functions.invoke("mailchimp-campaign", {
body: {
association_id: selectedAssoc,
subject: campaignSubject,
from_name: campaignFromName,
reply_to: campaignReplyTo,
html: campaignHtml,
send_now: sendNow,
},
});
if (error) throw error;
if (!data.success) throw new Error(data.error || "Campaign failed");
setLastCampaign({ web_id: data.web_id, sent: data.sent });
toast({
title: sendNow ? "Campaign sent!" : "Draft created",
description: sendNow
? "Mailchimp is delivering your campaign to the audience."
: "Saved as a draft in Mailchimp — review and send from there.",
});
if (sendNow) {
setCampaignSubject(""); setCampaignHtml("");
}
} catch (e: any) {
toast({ variant: "destructive", title: "Campaign failed", description: e.message });
} finally {
setSendingCampaign(false);
}
};
return (
<div className="container mx-auto p-6 max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Mail className="w-7 h-7 text-primary" />
<div>
<h1 className="text-2xl font-bold text-foreground">Mailchimp Integration</h1>
<p className="text-sm text-muted-foreground">Sync owners from each association into a Mailchimp audience for marketing campaigns.</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Select Association</CardTitle>
<CardDescription>Each association uses its own Mailchimp account and audience.</CardDescription>
</CardHeader>
<CardContent>
<Select value={selectedAssoc} onValueChange={setSelectedAssoc}>
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
<SelectContent>
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
{selectedAssoc && (
<p className="text-sm text-muted-foreground mt-3">
<span className="font-medium text-foreground">{ownerCount}</span> active owner(s) with email in this association.
</p>
)}
</CardContent>
</Card>
{selectedAssoc && (
<Card>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle>Mailchimp Credentials</CardTitle>
<CardDescription>
Get your API key from Mailchimp Account Extras API keys. The server prefix is the suffix after the dash (e.g. <code className="bg-muted px-1 rounded">us21</code>).
</CardDescription>
</div>
{config && (
<Badge variant="outline" className="bg-green-500/10 text-green-700 border-green-500/30">
<CheckCircle2 className="w-3 h-3 mr-1" /> Connected
</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label>API Key</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="abc123...-us21"
/>
</div>
<div>
<Label>Server Prefix</Label>
<Input
value={serverPrefix}
onChange={(e) => setServerPrefix(e.target.value)}
placeholder="us21"
/>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" onClick={handleTestAndLoad} disabled={loading || !apiKey || !serverPrefix}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Test & Load Audiences
</Button>
<Button variant="outline" onClick={() => setShowCreate(!showCreate)} disabled={!apiKey || !serverPrefix}>
<Plus className="w-4 h-4 mr-2" /> Create New Audience
</Button>
</div>
{showCreate && (
<div className="p-4 border rounded-lg bg-muted/30 space-y-3">
<Label>New audience name</Label>
<Input
value={newAudienceName}
onChange={(e) => setNewAudienceName(e.target.value)}
placeholder={associations.find(a => a.id === selectedAssoc)?.name || "HOA Owners"}
/>
<Button size="sm" onClick={handleCreateAudience} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Create in Mailchimp
</Button>
</div>
)}
{audiences.length > 0 && (
<div>
<Label>Audience</Label>
<Select value={selectedAudience} onValueChange={setSelectedAudience}>
<SelectTrigger><SelectValue placeholder="Pick an audience" /></SelectTrigger>
<SelectContent>
{audiences.map(a => (
<SelectItem key={a.id} value={a.id}>
{a.name} {a.stats?.member_count != null && `(${a.stats.member_count} members)`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{config?.audience_name && audiences.length === 0 && (
<p className="text-sm text-muted-foreground">
Currently linked: <span className="font-medium text-foreground">{config.audience_name}</span> click "Test & Load" to change.
</p>
)}
<div className="flex gap-2 pt-2">
<Button onClick={handleSave} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Save Configuration
</Button>
{config && (
<Button variant="destructive" onClick={handleDisconnect} disabled={loading}>
Disconnect
</Button>
)}
</div>
</CardContent>
</Card>
)}
{config?.audience_id && (
<Card>
<CardHeader>
<CardTitle>Sync Owners to Mailchimp</CardTitle>
<CardDescription>
Pushes all active owners with email to the linked audience. Existing members are updated; new members are added as subscribed.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm">
Audience: <span className="font-medium">{config.audience_name}</span>
</p>
<p className="text-sm text-muted-foreground">
{ownerCount} owner(s) will be synced.
</p>
</div>
<Button onClick={handleSync} disabled={syncing}>
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Sync Now
</Button>
</div>
{config.last_sync_at && (
<>
<Separator />
<div className="space-y-1 text-sm">
<p className="text-muted-foreground">Last sync</p>
<p>{format(new Date(config.last_sync_at), "PPpp")}</p>
<div className="flex items-center gap-2 mt-2">
{config.last_sync_status === "success" && (
<Badge className="bg-green-500/15 text-green-700 border-green-500/30" variant="outline">
<CheckCircle2 className="w-3 h-3 mr-1" /> Success
</Badge>
)}
{config.last_sync_status === "partial" && (
<Badge className="bg-yellow-500/15 text-yellow-700 border-yellow-500/30" variant="outline">
<AlertCircle className="w-3 h-3 mr-1" /> Partial
</Badge>
)}
{config.last_sync_status === "failed" && (
<Badge variant="destructive">
<AlertCircle className="w-3 h-3 mr-1" /> Failed
</Badge>
)}
<span className="text-muted-foreground">{config.last_sync_count} synced</span>
</div>
{config.last_sync_error && (
<p className="text-xs text-destructive mt-2 break-all">{config.last_sync_error}</p>
)}
</div>
</>
)}
</CardContent>
</Card>
)}
{selectedAssoc && !config?.audience_id && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-muted-foreground"><Send className="w-5 h-5" /> Send Campaign via Mailchimp</CardTitle>
<CardDescription>
Save your Mailchimp credentials and select an audience above to unlock the campaign sender.
</CardDescription>
</CardHeader>
</Card>
)}
{config?.audience_id && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Send className="w-5 h-5" /> Send Campaign via Mailchimp</CardTitle>
<CardDescription>
Compose and send an email campaign directly through Mailchimp to <span className="font-medium text-foreground">{config.audience_name}</span>. You can send immediately or save as a draft to review in Mailchimp.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label>Subject Line</Label>
<Input value={campaignSubject} onChange={(e) => setCampaignSubject(e.target.value)} placeholder="Important community update" />
</div>
<div>
<Label>From Name</Label>
<Input value={campaignFromName} onChange={(e) => setCampaignFromName(e.target.value)} placeholder={associations.find(a => a.id === selectedAssoc)?.name || "HOA Board"} />
</div>
</div>
<div>
<Label>Reply-To Email</Label>
<Input type="email" value={campaignReplyTo} onChange={(e) => setCampaignReplyTo(e.target.value)} placeholder="board@yourhoa.com" />
</div>
<div>
<Label>Email Content (HTML)</Label>
<Textarea
value={campaignHtml}
onChange={(e) => setCampaignHtml(e.target.value)}
placeholder="<h1>Hello owners</h1><p>Your message here...</p>"
rows={10}
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
Plain HTML supported. Mailchimp automatically appends required unsubscribe footers.
</p>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button onClick={() => handleSendCampaign(true)} disabled={sendingCampaign}>
{sendingCampaign ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Send className="w-4 h-4 mr-2" />}
Send Now to {ownerCount} Recipients
</Button>
<Button variant="outline" onClick={() => handleSendCampaign(false)} disabled={sendingCampaign}>
Save as Draft in Mailchimp
</Button>
</div>
{lastCampaign && (
<div className="p-3 rounded-md bg-muted/40 border text-sm flex items-center justify-between gap-2">
<span>
{lastCampaign.sent ? "✓ Campaign sent successfully" : "✓ Draft saved in Mailchimp"}
</span>
{lastCampaign.web_id && (
<a
href={`https://${config.server_prefix}.admin.mailchimp.com/campaigns/edit?id=${lastCampaign.web_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
Open in Mailchimp <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}