PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.27
Booktics – Appointment Booking Calendar for Service Businesses v1.0.27
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / module / woocommerce-addon / hook / checkout-fields-hook.php

checkout-fields-hook.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.27, at core/module/woocommerce-addon/hook/checkout-fields-hook.php

446 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Module\Woocommerce_Addon\Hook;
4
5 if ( ! defined( 'ABSPATH' ) ) exit;
6
7 use Booktics\Contracts\Hookable_Service_Contract;
8 use Booktics\Module\Woocommerce_Addon\Service\Cart_Handoff;
9 use Booktics\Order\Services\Order_Key;
10
11 /**
12 * Shapes the WooCommerce checkout for a booking, and decides where the
13 * customer lands afterwards.
14 *
15 * The booking form already asked for a name and an email, so asking again is
16 * friction. With "Show billing fields in checkout" off, the checkout is
17 * stripped back and prefilled from the booking instead.
18 *
19 * How completely depends on the checkout the store runs, and this deliberately
20 * does not replace it with one of ours — the store's own checkout
21 * customisations and plugins keep applying:
22 *
23 * - Classic checkout: every billing, shipping and order field is removed and
24 * the values are injected server side.
25 * - Checkout block: the customer object is prefilled and the address fields are
26 * hidden through the country-locale filter, which is the one lever the block
27 * honours for core fields (its own field list is hardcoded and ignores
28 * woocommerce_default_address_fields). Email and name are not address fields
29 * and stay visible: the block treats them as structurally required and there
30 * is no supported way to remove them.
31 *
32 * With no customer address, WooCommerce falls back to the shop base address
33 * for tax, which is the trade the setting makes.
34 */
35 class Checkout_Fields_Hook implements Hookable_Service_Contract {
36
37 /**
38 * Register WordPress hooks.
39 *
40 * @return void
41 */
42 public function register() {
43 add_filter( 'woocommerce_checkout_fields', array( $this, 'strip_checkout_fields' ), 20 );
44 add_filter( 'woocommerce_checkout_posted_data', array( $this, 'inject_customer_data' ) );
45 add_filter( 'woocommerce_checkout_get_value', array( $this, 'prefill_checkout_field' ), 10, 2 );
46
47 // Classic reads the first, the checkout block only the second. Both are
48 // filtered so the setting hides the address fields on either checkout.
49 add_filter( 'woocommerce_default_address_fields', array( $this, 'relax_address_fields' ), 20 );
50 add_filter( 'woocommerce_get_country_locale', array( $this, 'relax_block_address_fields' ), 20 );
51
52 add_action( 'woocommerce_before_checkout_form', array( $this, 'prefill_customer_object' ) );
53 add_action( 'woocommerce_before_cart', array( $this, 'prefill_customer_object' ) );
54
55 // The checkout block never fires the two actions above; it renders from
56 // the customer object the Store API cart response carries. Seeding that
57 // object on wp_loaded (which runs for the REST cart request too) is what
58 // prefills the block, and is what lets the hidden name field still hold
59 // a value so the order does not fail validation once it is out of sight.
60 add_action( 'wp_loaded', array( $this, 'prefill_customer_object' ) );
61
62 // Classic redirects through the first filter, the block through the
63 // second, so the thank-you setting is honoured on either checkout.
64 add_filter( 'woocommerce_get_return_url', array( $this, 'return_url' ), 10, 2 );
65 add_filter( 'woocommerce_get_checkout_order_received_url', array( $this, 'return_url' ), 10, 2 );
66 add_action( 'woocommerce_thankyou', array( $this, 'thankyou_details' ) );
67 }
68
69 /**
70 * Remove the checkout fields when the setting says to.
71 *
72 * Classic checkout only: the Checkout block does not read this filter.
73 *
74 * @param array $fields Checkout fields.
75 *
76 * @return array
77 */
78 public function strip_checkout_fields( $fields ) {
79 if ( ! $this->should_hide_fields() ) {
80 return $fields;
81 }
82
83 $fields['billing'] = array();
84 $fields['shipping'] = array();
85 $fields['order'] = array();
86
87 return $fields;
88 }
89
90 /**
91 * Supply the order data the stripped fields would have collected.
92 *
93 * @param array $data Posted checkout data.
94 *
95 * @return array
96 */
97 public function inject_customer_data( $data ) {
98 $customer = $this->booking_customer();
99
100 if ( ! $customer || ! $this->should_hide_fields() ) {
101 return $data;
102 }
103
104 foreach ( array( 'first_name', 'last_name', 'email', 'phone' ) as $key ) {
105 if ( ! empty( $customer[ $key ] ) ) {
106 $data[ 'billing_' . $key ] = $customer[ $key ];
107 }
108 }
109
110 return $data;
111 }
112
113 /**
114 * Prefill a visible checkout field from the booking.
115 *
116 * @param mixed $value Current value.
117 * @param string $input Field name.
118 *
119 * @return mixed
120 */
121 public function prefill_checkout_field( $value, $input ) {
122 if ( ! empty( $value ) ) {
123 return $value;
124 }
125
126 $customer = $this->booking_customer();
127
128 if ( ! $customer ) {
129 return $value;
130 }
131
132 $map = array(
133 'billing_first_name' => 'first_name',
134 'billing_last_name' => 'last_name',
135 'billing_email' => 'email',
136 'billing_phone' => 'phone',
137 );
138
139 return isset( $map[ $input ], $customer[ $map[ $input ] ] ) ? $customer[ $map[ $input ] ] : $value;
140 }
141
142 /**
143 * Hide and un-require the address fields on block checkouts.
144 *
145 * These locale filters are the one lever both checkouts honour. Email and
146 * name are not address fields and stay visible on the block.
147 *
148 * @param array $fields Default address fields.
149 *
150 * @return array
151 */
152 public function relax_address_fields( $fields ) {
153 if ( ! $this->should_hide_fields() || ! $this->cart_has_booking() ) {
154 return $fields;
155 }
156
157 foreach ( $this->address_fields_to_hide() as $key ) {
158 if ( isset( $fields[ $key ] ) ) {
159 $fields[ $key ]['required'] = false;
160 $fields[ $key ]['hidden'] = true;
161 }
162 }
163
164 return $fields;
165 }
166
167 /**
168 * Hide and un-require the address fields on the checkout block.
169 *
170 * The block builds its address form from the country locale, not from
171 * woocommerce_default_address_fields, so relax_address_fields never reaches
172 * it. Marking the fields hidden and optional here is what actually drops
173 * them from the block, and un-requiring them keeps the order from failing
174 * validation once they are gone. Applied to every country's locale (plus the
175 * shared default) so it holds whichever country the customer picks.
176 *
177 * @param array $locale Country locale, keyed by country code.
178 *
179 * @return array
180 */
181 public function relax_block_address_fields( $locale ) {
182 if ( ! $this->should_hide_fields() ) {
183 return $locale;
184 }
185
186 foreach ( $locale as $country => $fields ) {
187 if ( ! is_array( $fields ) ) {
188 continue;
189 }
190
191 foreach ( $this->block_fields_to_hide() as $key ) {
192 $locale[ $country ][ $key ]['required'] = false;
193 $locale[ $country ][ $key ]['hidden'] = true;
194 }
195 }
196
197 return $locale;
198 }
199
200 /**
201 * Address field keys stripped when billing fields are turned off.
202 *
203 * @return array<int, string>
204 */
205 private function address_fields_to_hide(): array {
206 return array( 'company', 'address_1', 'address_2', 'city', 'state', 'postcode' );
207 }
208
209 /**
210 * Every field the block hides in its billing group.
211 *
212 * Wider than the classic set on purpose: the block honours the locale for
213 * the name and phone too, so those go as well and the booking's own values
214 * (supplied by prefill_customer_object) keep the hidden, now-optional name
215 * from failing validation. Two fields are deliberately left visible: email,
216 * which lives in its own contact section and the block will not let go of,
217 * and country, which the block always renders and, left blank, is the
218 * selection that makes it load the payment methods in the first place.
219 *
220 * @return array<int, string>
221 */
222 private function block_fields_to_hide(): array {
223 return array_merge(
224 $this->address_fields_to_hide(),
225 array( 'first_name', 'last_name', 'phone' )
226 );
227 }
228
229 /**
230 * Seed the WooCommerce customer with the booking's contact details.
231 *
232 * The block checkout renders from the customer object rather than from
233 * checkout field values, so this is what prefills it there.
234 *
235 * @return void
236 */
237 public function prefill_customer_object() {
238 $customer = $this->booking_customer();
239
240 if ( ! $customer || ! function_exists( 'WC' ) || ! WC()->customer ) {
241 return;
242 }
243
244 if ( ! WC()->customer->get_billing_email() && ! empty( $customer['email'] ) ) {
245 WC()->customer->set_billing_email( $customer['email'] );
246 }
247
248 if ( ! WC()->customer->get_billing_first_name() && ! empty( $customer['first_name'] ) ) {
249 WC()->customer->set_billing_first_name( $customer['first_name'] );
250 }
251
252 if ( ! WC()->customer->get_billing_last_name() && ! empty( $customer['last_name'] ) ) {
253 WC()->customer->set_billing_last_name( $customer['last_name'] );
254 }
255
256 if ( ! WC()->customer->get_billing_phone() && ! empty( $customer['phone'] ) ) {
257 WC()->customer->set_billing_phone( $customer['phone'] );
258 }
259 }
260
261 /**
262 * Send the customer back to the booking page when the setting says to.
263 *
264 * The order id and its view key travel in the URL rather than in session
265 * storage, which Safari and in-app browsers can drop across an off-site
266 * gateway round trip.
267 *
268 * @param string $url Default return URL.
269 * @param \WC_Order $order Order just paid.
270 *
271 * @return string
272 */
273 public function return_url( $url, $order = null ) {
274 if ( ! $order instanceof \WC_Order ) {
275 return $url;
276 }
277
278 if ( 'booktics' !== booktics_get_option( 'wc_thankyou_page', 'woocommerce' ) ) {
279 return $url;
280 }
281
282 $booking = $this->booking_on_order( $order );
283
284 if ( ! $booking || empty( $booking['return_url'] ) ) {
285 return $url;
286 }
287
288 return add_query_arg(
289 array(
290 'order_id' => (int) $booking['order_id'],
291 'order_key' => Order_Key::make( (int) $booking['order_id'], (string) ( $booking['order_no'] ?? '' ) ),
292 'booktics_payment' => 'woocommerce',
293 ),
294 $booking['return_url']
295 );
296 }
297
298 /**
299 * Show the booking on WooCommerce's own order-received page.
300 *
301 * @param int $order_id Order id.
302 *
303 * @return void
304 */
305 public function thankyou_details( $order_id ) {
306 $order = wc_get_order( $order_id );
307
308 if ( ! $order instanceof \WC_Order ) {
309 return;
310 }
311
312 $booking = $this->booking_on_order( $order );
313
314 if ( ! $booking ) {
315 return;
316 }
317
318 echo '<section class="booktics-thankyou-booking"><h2>' . esc_html__( 'Your booking', 'booktics' ) . '</h2><ul>';
319
320 foreach ( $this->booking_summary( $booking ) as $label => $value ) {
321 printf( '<li><strong>%s:</strong> %s</li>', esc_html( $label ), esc_html( $value ) );
322 }
323
324 echo '</ul></section>';
325 }
326
327 /**
328 * Readable summary rows for the thank-you page.
329 *
330 * @param array $booking Booking payload from the line item.
331 *
332 * @return array<string, string>
333 */
334 private function booking_summary( array $booking ): array {
335 $rows = array();
336
337 if ( ! empty( $booking['service'] ) ) {
338 $rows[ __( 'Service', 'booktics' ) ] = $booking['service'];
339 }
340
341 if ( ! empty( $booking['date'] ) ) {
342 $timestamp = strtotime( $booking['date'] );
343
344 $rows[ __( 'Date', 'booktics' ) ] = $timestamp ? wp_date( get_option( 'date_format' ), $timestamp ) : $booking['date'];
345 }
346
347 if ( ! empty( $booking['start_time'] ) ) {
348 $rows[ __( 'Time', 'booktics' ) ] = empty( $booking['end_time'] )
349 ? $booking['start_time']
350 : $booking['start_time'] . ' - ' . $booking['end_time'];
351 }
352
353 if ( ! empty( $booking['team_member'] ) ) {
354 $rows[ __( 'Team member', 'booktics' ) ] = $booking['team_member'];
355 }
356
357 return $rows;
358 }
359
360 /**
361 * Whether the checkout fields should be stripped for this session.
362 *
363 * @return bool
364 */
365 private function should_hide_fields(): bool {
366 if ( booktics_get_option( 'wc_show_billing_fields' ) ) {
367 return false;
368 }
369
370 return $this->cart_has_booking();
371 }
372
373 /**
374 * Is a booking in the cart right now?
375 *
376 * Guards every field change so a normal shop checkout is never touched.
377 *
378 * @return bool
379 */
380 private function cart_has_booking(): bool {
381 return null !== $this->booking_in_cart();
382 }
383
384 /**
385 * Contact details of the booking currently in the cart.
386 *
387 * @return array|null
388 */
389 private function booking_customer() {
390 $booking = $this->booking_in_cart();
391
392 return $booking && ! empty( $booking['customer'] ) ? $booking['customer'] : null;
393 }
394
395 /**
396 * Booking payload sitting in the cart, if any.
397 *
398 * @return array|null
399 */
400 private function booking_in_cart() {
401 if ( ! function_exists( 'WC' ) || ! WC()->cart instanceof \WC_Cart ) {
402 return null;
403 }
404
405 foreach ( WC()->cart->get_cart() as $cart_item ) {
406 if ( ! empty( $cart_item[ Cart_Handoff::CART_KEY ] ) ) {
407 return $cart_item[ Cart_Handoff::CART_KEY ];
408 }
409 }
410
411 return null;
412 }
413
414 /**
415 * Rebuild the booking payload from an order's line items.
416 *
417 * Used after checkout, when the cart is already gone.
418 *
419 * @param \WC_Order $order Order.
420 *
421 * @return array|null
422 */
423 private function booking_on_order( $order ) {
424 foreach ( $order->get_items() as $item ) {
425 $booktics_order_id = (int) $item->get_meta( '_booktics_order_id' );
426
427 if ( ! $booktics_order_id ) {
428 continue;
429 }
430
431 return array(
432 'order_id' => $booktics_order_id,
433 'order_no' => (string) $order->get_meta( '_booktics_order_no' ),
434 'service' => $item->get_name(),
435 'date' => (string) $item->get_meta( __( 'Date', 'booktics' ) ),
436 'start_time' => (string) $item->get_meta( __( 'Time', 'booktics' ) ),
437 'end_time' => '',
438 'team_member' => (string) $item->get_meta( __( 'Team member', 'booktics' ) ),
439 'return_url' => (string) $order->get_meta( '_booktics_return_url' ),
440 );
441 }
442
443 return null;
444 }
445 }
446