$values Keyed: role, vendor_type, performer_type, * activity_type, location_state, max_attendance, event_start, event_end, customer_email. * @return array INSTANDA wire variables (VendorType omitted per D1). */ public function to_payload(array $values, string $timezone): array { $tz = new \DateTimeZone($timezone); $start = $this->parse_date((string) ($values['event_start'] ?? ''), $tz); $end = $this->parse_date((string) ($values['event_end'] ?? ''), $tz); $payload = [ 'Role' => (string) ($values['role'] ?? ''), 'ActivityType' => (string) ($values['activity_type'] ?? ''), 'PQ_LocationState_CHOICE' => (string) ($values['location_state'] ?? ''), 'PQ_MaximumDailyAttendance_NUM' => (int) ($values['max_attendance'] ?? 0), 'PQ_EventStart_DATE' => $start ? $start->format('Y-m-d') : '', 'PQ_EventEnd_DATE' => $end ? $end->format('Y-m-d') : '', 'PQ_EventConsecutiveDays_NUM' => ($start && $end) ? $this->consecutive_days($start, $end) : 0, 'PQ_CustomerEmailAddress_TXT' => trim((string) ($values['customer_email'] ?? '')), ]; // D1/D4 - VendorType is conditional and never sent empty. $vendor_type = $this->resolve_vendor_type($values); if ($vendor_type !== null) { $payload['VendorType'] = $vendor_type; } return $payload; } /** * Server-side re-validation. Returns a list of stable error codes (empty = valid) * that the submission layer maps to friendly inline messages. * * @param array $values See to_payload(). * @param \DateTimeImmutable|null $now Injectable clock for the "start in the past" * check; defaults to now in $timezone. * @return list */ public function validate(array $values, string $timezone, ?\DateTimeImmutable $now = null): array { $errors = []; $tz = new \DateTimeZone($timezone); $role = (string) ($values['role'] ?? ''); if ($role === '') { $errors[] = 'role_required'; } // D2/D3 - the role's sub-type dropdown is required when that role is selected. if ($role === self::ROLE_VENDOR && (string) ($values['vendor_type'] ?? '') === '') { $errors[] = 'vendor_type_required'; } if ($role === self::ROLE_PERFORMER && (string) ($values['performer_type'] ?? '') === '') { $errors[] = 'performer_type_required'; } if ((string) ($values['activity_type'] ?? '') === '') { $errors[] = 'activity_type_required'; } if ((string) ($values['location_state'] ?? '') === '') { $errors[] = 'location_required'; } // D10 - attendance is a positive integer. if (!$this->is_positive_integer($values['max_attendance'] ?? null)) { $errors[] = 'attendance_not_positive_integer'; } // D9 - a well-formed email. $email = trim((string) ($values['customer_email'] ?? '')); if ($email === '') { $errors[] = 'email_required'; } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'email_invalid'; } // D6/D7 - both dates required, YYYY-MM-DD, end >= start, start today-or-later (site tz). $start = $this->parse_date((string) ($values['event_start'] ?? ''), $tz); $end = $this->parse_date((string) ($values['event_end'] ?? ''), $tz); if ($start === null) { $errors[] = 'start_required'; } if ($end === null) { $errors[] = 'end_required'; } if ($start !== null && $end !== null) { if ($end < $start) { $errors[] = 'end_before_start'; } $today_midnight = ($now ?? new \DateTimeImmutable('now', $tz)) ->setTimezone($tz) ->setTime(0, 0, 0); if ($start < $today_midnight) { $errors[] = 'start_in_past'; } } return $errors; } /** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */ private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int { return (int) $start->diff($end)->days + 1; } /** D1/D4 - resolve the one VendorType value, or null to omit the key entirely. */ private function resolve_vendor_type(array $values): ?string { $role = (string) ($values['role'] ?? ''); if ($role === self::ROLE_EVENT_HOST) { return null; // D1 - omit, never empty string. } // D4 - trust the role, then read whichever sub-type field that role populates. $value = $role === self::ROLE_PERFORMER ? (string) ($values['performer_type'] ?? '') : (string) ($values['vendor_type'] ?? ''); return $value !== '' ? $value : null; } /** Parse a strict YYYY-MM-DD date at midnight in $tz; null if missing or malformed. */ private function parse_date(string $value, \DateTimeZone $tz): ?\DateTimeImmutable { $value = trim($value); if ($value === '') { return null; } $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, $tz); // createFromFormat is lenient on overflow (e.g. 2026-13-40); reject anything // that does not round-trip to the exact input. if ($date === false || $date->format('Y-m-d') !== $value) { return null; } return $date; } private function is_positive_integer(mixed $value): bool { if (is_int($value)) { return $value > 0; } if (is_string($value)) { $value = trim($value); return $value !== '' && ctype_digit($value) && (int) $value > 0; } return false; } }