diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0b3bd0..440262c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,23 +26,8 @@ jobs: - name: Build run: bun run build - deploy: - # Auto-deploy to the VPS (avria.cloud) on every push to main. - # The SSH key is restricted on the server (forced command): it can only - # run /home/avria/deploy.sh, which pulls main, builds, and rsyncs - # dist/ -> public_html. The command string below is therefore ignored - # by the server but kept descriptive. - needs: build - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - name: Deploy to VPS - run: | - mkdir -p ~/.ssh - printf '%s\n' "$VPS_DEPLOY_KEY" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - printf '%s\n' "$VPS_HOST_KEY" > ~/.ssh/known_hosts - ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes avria@2.25.155.250 "deploy main" - env: - VPS_DEPLOY_KEY: ${{ secrets.VPS_DEPLOY_KEY }} - VPS_HOST_KEY: ${{ secrets.VPS_HOST_KEY }} +# Deployment: the VPS (avria.cloud) polls this repo every 2 minutes via +# /home/avria/auto-deploy.sh (cron, user avria) and runs deploy.sh when main +# moves — pull/build/rsync to public_html. CI here is a build check only; +# pushing SSH from Actions doesn't work because Hostinger's edge firewall +# blocks the runners' IPs. diff --git a/src/App.tsx b/src/App.tsx index 93c5397..26eff8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => ( } /> } /> } /> - } /> + } /> } /> {/* Financial module */} } /> diff --git a/src/components/SettingsSidebar.tsx b/src/components/SettingsSidebar.tsx index 84327a7..f43cc03 100644 --- a/src/components/SettingsSidebar.tsx +++ b/src/components/SettingsSidebar.tsx @@ -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 }, ], }, ]; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 4a293bb..164f2b0 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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[] + } + 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"], }, diff --git a/src/pages/ARCApplicationsPage.tsx b/src/pages/ARCApplicationsPage.tsx index cdab033..923678e 100644 --- a/src/pages/ARCApplicationsPage.tsx +++ b/src/pages/ARCApplicationsPage.tsx @@ -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 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({ > Export Record + {isAdmin && ( + + + + + + + Delete this ARC application? + + This permanently removes “{app.title}” along with its votes, comments, and attached files. This cannot be undone. + + + + Cancel + onDelete(app)} + > + Delete + + + + + )} {app.submitted_date ? format(new Date(app.submitted_date), "MMMM d, yyyy") : "—"} diff --git a/src/pages/ComposeEmailPage.tsx b/src/pages/ComposeEmailPage.tsx index f1e45bb..26346ab 100644 --- a/src/pages/ComposeEmailPage.tsx +++ b/src/pages/ComposeEmailPage.tsx @@ -41,10 +41,6 @@ export default function ComposeEmailPage() { const [sending, setSending] = useState(false); const [tab, setTab] = useState("content"); const [pickerOpen, setPickerOpen] = useState(null); - const [sendMethod, setSendMethod] = useState<"smtp" | "mailchimp">("smtp"); - const [mailchimpConfig, setMailchimpConfig] = useState(null); - const [mcFromName, setMcFromName] = useState(""); - const [mcReplyTo, setMcReplyTo] = useState(""); const [signatures, setSignatures] = useState([]); const [signatureId, setSignatureId] = useState("none"); const [signatureHtml, setSignatureHtml] = useState(""); @@ -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() {
- Send Method - - - {sendMethod === "mailchimp" && !mailchimpConfig?.audience_id && ( -

- Configure Mailchimp and select an audience under the Mailchimp page first. -

- )} - {sendMethod === "mailchimp" && mailchimpConfig?.audience_id && ( -

- Will send to your Mailchimp audience for {selectedAssociation?.name}. To/Cc/Bcc and SMTP sender are ignored. -

- )} -
-
- - - {sendMethod === "mailchimp" ? "Campaign Details" : "Sender & Recipients"} + Sender & Recipients - {sendMethod === "smtp" ? ( -
- - -
- ) : ( - <> -
- - setMcFromName(e.target.value)} placeholder="Association Name" /> -
-
- - setMcReplyTo(e.target.value)} placeholder="reply@example.com" /> -
- - )} - {sendMethod === "smtp" && ( +
+ + +
+ {( <>
@@ -543,15 +438,9 @@ export default function ComposeEmailPage() {
- {sendMethod === "mailchimp" && ( - - )}
diff --git a/src/pages/HostingerReachPage.tsx b/src/pages/HostingerReachPage.tsx new file mode 100644 index 0000000..573a9b5 --- /dev/null +++ b/src/pages/HostingerReachPage.tsx @@ -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([]); + const [selectedAssoc, setSelectedAssoc] = useState(""); + + // Global token config + const [configId, setConfigId] = useState(null); + const [apiToken, setApiToken] = useState(""); + const [testing, setTesting] = useState(false); + const [savingToken, setSavingToken] = useState(false); + + // Per-association sync state + const [ownerCount, setOwnerCount] = useState(0); + const [segment, setSegment] = useState(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 ( +
+
+ +
+

Hostinger Reach

+

+ Push each association's owners into Hostinger Reach as contacts, organized into a per-association segment. Compose and send campaigns from the Reach dashboard. +

+
+
+ + {/* Connection */} + + +
+ API Connection + + One account-wide token. Create it in hPanel → Reach → API. It's stored securely and never shown again after saving. + +
+ {configId && ( + + Connected + + )} +
+ +
+ + setApiToken(e.target.value)} + placeholder={configId ? "•••••••• (saved) — paste a new token to replace" : "Paste your API token"} + /> +
+
+ + +
+
+
+ + {/* Sync */} + + + Sync Owners to Reach + Each association's active owners (with email) are pushed as contacts and grouped into their own Reach segment. + + +
+ + +
+ +
+
+

+ {ownerCount} active owner(s) with email + {segment?.segment_name && <> · segment: {segment.segment_name}} +

+

Re-running is safe — existing contacts aren't duplicated.

+
+ +
+ + {segment?.last_sync_at && ( + <> + +
+

Last sync

+

{format(new Date(segment.last_sync_at), "PPpp")}

+
+ {segment.last_sync_status === "success" && ( + + Success + + )} + {segment.last_sync_status === "partial" && ( + + Partial + + )} + {segment.last_sync_status === "failed" && ( + Failed + )} + {segment.last_sync_count ?? 0} synced +
+ {segment.last_sync_error && ( +

{segment.last_sync_error}

+ )} +
+ + )} +
+
+ + {/* Campaigns live in Reach */} + + + Send Campaigns in Reach + + Hostinger Reach campaigns are composed and sent from the Reach dashboard. Sync owners here, then target the + {assocName ? <> {assocName} : " association"} segment in Reach. + + + + + Open Hostinger Reach + + + +
+ ); +} diff --git a/src/pages/MailchimpPage.tsx b/src/pages/MailchimpPage.tsx deleted file mode 100644 index e64add9..0000000 --- a/src/pages/MailchimpPage.tsx +++ /dev/null @@ -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([]); - const [selectedAssoc, setSelectedAssoc] = useState(""); - const [config, setConfig] = useState(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([]); - const [selectedAudience, setSelectedAudience] = useState(""); - const [newAudienceName, setNewAudienceName] = useState(""); - const [showCreate, setShowCreate] = useState(false); - const [ownerCount, setOwnerCount] = useState(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 ( -
-
- -
-

Mailchimp Integration

-

Sync owners from each association into a Mailchimp audience for marketing campaigns.

-
-
- - - - Select Association - Each association uses its own Mailchimp account and audience. - - - - {selectedAssoc && ( -

- {ownerCount} active owner(s) with email in this association. -

- )} -
-
- - {selectedAssoc && ( - - -
- Mailchimp Credentials - - Get your API key from Mailchimp → Account → Extras → API keys. The server prefix is the suffix after the dash (e.g. us21). - -
- {config && ( - - Connected - - )} -
- -
-
- - handleApiKeyChange(e.target.value)} - placeholder="abc123...-us21" - /> -
-
- - setServerPrefix(e.target.value)} - placeholder="us21" - /> -
-
- -
- - -
- - {showCreate && ( -
- - setNewAudienceName(e.target.value)} - placeholder={associations.find(a => a.id === selectedAssoc)?.name || "HOA Owners"} - /> - -
- )} - - {audiences.length > 0 && ( -
- - -
- )} - - {config?.audience_name && audiences.length === 0 && ( -

- Currently linked: {config.audience_name} — click "Test & Load" to change. -

- )} - -
- - {config && ( - - )} -
-
-
- )} - - {config?.audience_id && ( - - - Sync Owners to Mailchimp - - Pushes all active owners with email to the linked audience. Existing members are updated; new members are added as subscribed. - - - -
-
-

- Audience: {config.audience_name} -

-

- {ownerCount} owner(s) will be synced. -

-
- -
- - {config.last_sync_at && ( - <> - -
-

Last sync

-

{format(new Date(config.last_sync_at), "PPpp")}

-
- {config.last_sync_status === "success" && ( - - Success - - )} - {config.last_sync_status === "partial" && ( - - Partial - - )} - {config.last_sync_status === "failed" && ( - - Failed - - )} - {config.last_sync_count} synced -
- {config.last_sync_error && ( -

{config.last_sync_error}

- )} -
- - )} -
-
- )} - - {selectedAssoc && !config?.audience_id && ( - - - Send Campaign via Mailchimp - - Save your Mailchimp credentials and select an audience above to unlock the campaign sender. - - - - )} - - {config?.audience_id && ( - - - Send Campaign via Mailchimp - - Compose and send an email campaign directly through Mailchimp to {config.audience_name}. You can send immediately or save as a draft to review in Mailchimp. - - - -
-
- - setCampaignSubject(e.target.value)} placeholder="Important community update" /> -
-
- - setCampaignFromName(e.target.value)} placeholder={associations.find(a => a.id === selectedAssoc)?.name || "HOA Board"} /> -
-
-
- - setCampaignReplyTo(e.target.value)} placeholder="board@yourhoa.com" /> -
-
- -