mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
+2
-2
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user