open source
GPL-2.0-or-later
1.0.x-dev
return hooks()
    ->action(
        'woocommerce_order_status_completed',
        action(ExportOrderActionInterface::class),
    )
    ->filter(
        'woocommerce_billing_fields',
        action(AddCompanyCodeFieldActionInterface::class),
    )
    ->activation(
        file(),
        action(GenerateWebhookSecretActionInterface::class),
    );
readonly class AddCompanyCodeFieldAction implements AddCompanyCodeFieldActionInterface
{
    public function __construct(
        protected TranslatorInterface $translator,
    ) {
    }

    public function __invoke(
        RequestInterface $request,
        array $fields,
        string $country,
    ): array {
        if ($country === 'LT') {
            $fields['billing_company_code'] = [
                'type' => 'text',
                'label' => $this->translator->translate('Company code', 'label'),
                'placeholder' => $this->translator->translate('Company code', 'placeholder'),
            ];
        }

        return $fields;
    }
}
readonly class ExportOrderAction implements ExportOrderActionInterface
{
    public function __construct(
        protected RequestBuilderInterface $requestBuilder,
        protected ClientInterface $client,
        protected TranslatorInterface $translator,
    ) {
    }

    public function __invoke(
        RequestInterface $request,
        int $orderId,
        WC_Order $order,
        array $statusTransition,
    ): void {
        $response = $this->client->send(
            $this->requestBuilder
                ->method(Method::Post)
                ->url('https://api.example.com/import-order')
                ->body(
                    [
                        'number' => $order->get_order_number(),
                        'email' => $order->get_billing_email(),
                        'total' => $order->get_total(),
                    ],
                    'application/json',
                )
                ->build()
        );

        if ($response->statusCode() >= 300) {
            $order->add_order_note(
                sprintf(
                    $this->translator->translate('ERP export failed with HTTP %d.'),
                    $response->statusCode(),
                ),
            );
        }
    }
}
readonly class GenerateWebhookSecretAction implements GenerateWebhookSecretActionInterface
{
    public function __construct(
        protected OptionsInterface $options,
    ) {
    }

    public function __invoke(bool $networkWide): void
    {
        $this->options->add(
            'webhook_secret',
            wp_generate_password(64, false),
        );
    }
}
return routes()
    ->rest(
        'shop/v1',
        'pickup-points/(?P<code>[\w-]+)',
        action(GetPickupPointByCodeActionInterface::class),
        Method::Get,
    )
    ->adminAjax(
        'nearest_pickup_points',
        action(GetNearestPickupPointsActionInterface::class),
        fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
            ->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
                ->bodyQuery('latitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->float()
                    ->min(-90)
                    ->max(90))
                ->bodyQuery('longitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->float()
                    ->min(-180)
                    ->max(180))),
    )
    ->feed(
        'kaina24.xml',
        action(GetKaina24FeedActionInterface::class),
    );
readonly class GetKaina24FeedAction implements GetKaina24FeedActionInterface
{
    public function __construct(
        protected DatabaseInterface $database,
        protected ResponseBuilderInterface $responseBuilder,
    ) {
    }

    public function __invoke(RequestInterface $request): ResponseBuilderInterface
    {
        $products = $this->database->select(
            'SELECT posts.ID, posts.post_title, wc_product_meta_lookup.sku, wc_product_meta_lookup.min_price FROM :posts AS posts JOIN :wc_product_meta_lookup AS wc_product_meta_lookup ON wc_product_meta_lookup.product_id = posts.ID WHERE posts.post_status = %s AND wc_product_meta_lookup.stock_status = %s',
            'publish',
            'instock',
        );

        return $this->responseBuilder
            ->statusCode(200)
            ->header('Cache-Control', 'public, max-age=3600')
            ->body($products, 'application/xml');
    }
}
readonly class GetNearestPickupPointsAction implements GetNearestPickupPointsActionInterface
{
    public function __construct(
        protected DatabaseInterface $database,
        protected JsonInterface $json,
        protected ResponseBuilderInterface $responseBuilder,
    ) {
    }

    public function __invoke(RequestInterface $request): ResponseBuilderInterface
    {
        $bodyQuery = $request->method() === Method::Post ? $request->body() : $request->url()->query();

        $pickupPoints = $this->database->select(
            'SELECT code, name, latitude, longitude, sizes, 6371 * ACOS(SIN(RADIANS(latitude)) * SIN(RADIANS(%f)) + COS(RADIANS(latitude)) * COS(RADIANS(%f)) * COS(RADIANS(longitude - %f))) AS distance FROM :pickup_points ORDER BY distance LIMIT 10',
            $bodyQuery->get('latitude'),
            $bodyQuery->get('latitude'),
            $bodyQuery->get('longitude'),
        );

        foreach ($pickupPoints as $key => $pickupPoint) {
            $pickupPoints[$key]['sizes'] = $this->json->decode($pickupPoint['sizes']);
        }

        return $this->responseBuilder
            ->statusCode(200)
            ->body($pickupPoints, 'application/json');
    }
}
readonly class GetPickupPointByCodeAction implements GetPickupPointByCodeActionInterface
{
    public function __construct(
        protected DatabaseInterface $database,
        protected JsonInterface $json,
        protected ResponseBuilderInterface $responseBuilder,
    ) {
    }

    public function __invoke(
        RequestInterface $request,
        string $code,
    ): ResponseBuilderInterface {
        $pickupPoints = $this->database->select(
            'SELECT code, name, latitude, longitude, sizes FROM :pickup_points WHERE code = %s',
            $code,
        );

        if ($pickupPoints === []) {
            return $this->responseBuilder
                ->statusCode(404);
        }

        foreach ($pickupPoints as $key => $pickupPoint) {
            $pickupPoints[$key]['sizes'] = $this->json->decode($pickupPoint['sizes']);
        }

        return $this->responseBuilder
            ->statusCode(200)
            ->body($pickupPoints[0], 'application/json');
    }
}
return hooks()
    ->group(
        fn(HooksBuilderInterface $hooksBuilder) => $hooksBuilder
            ->action(
                'created_product_cat',
                action(UpdateMinimumAgeActionInterface::class),
            )
            ->action(
                'edited_product_cat',
                action(UpdateMinimumAgeActionInterface::class),
            ),
        fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
            ->verifyNonce('nonce', 'update_minimum_age')
            ->currentUserCan(Capability::ManageWooCommerce)
            ->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
                ->body('minimum_age', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->enum(MinimumAge::class))),
    )
    ->action(
        'woocommerce_process_product_meta',
        action(UpdateWholesalePriceActionInterface::class),
        10,
        fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
            ->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
                ->condition(
                    fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
                        ->body('wholesale_price', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                            ->isNotEmpty()),
                    fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
                        ->body('wholesale_price', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                            ->float())
                        ->compareFloats(fn(ComparisonValidatorBuilderInterface $comparisonValidatorBuilder) => $comparisonValidatorBuilder
                            ->body('wholesale_price')
                            ->lessThan()
                            ->toBody('_regular_price')),
                )),
    );
return routes()
    ->rest(
        'shop/v1',
        'pickup-points',
        action(ImportPickupPointsActionInterface::class),
        Method::Put,
        fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
            ->currentUserCan(Capability::ManageWooCommerce)
            ->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
                ->body('pickup_points', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->array()
                    ->isNotEmpty())
                ->body('pickup_points[*].code', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->string()
                    ->isNotEmpty())
                ->body('pickup_points[*].name', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->string()
                    ->isNotEmpty())
                ->body('pickup_points[*].latitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->float()
                    ->min(-90)
                    ->max(90))
                ->body('pickup_points[*].longitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->float()
                    ->min(-180)
                    ->max(180))
                ->body('pickup_points[*].sizes', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->array()
                    ->isNotEmpty())
                ->body('pickup_points[*].sizes[*]', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
                    ->enum(Size::class)))
            ->transaction(),
    );
readonly class ImportPickupPointsAction implements ImportPickupPointsActionInterface
{
    public function __construct(
        protected DatabaseInterface $database,
        protected JsonInterface $json,
        protected ResponseBuilderInterface $responseBuilder,
    ) {
    }

    public function __invoke(RequestInterface $request): ResponseBuilderInterface
    {
        $this->database->delete('DELETE FROM :pickup_points');

        $this->database->insert(
            'INSERT INTO :pickup_points (code, name, latitude, longitude, sizes) VALUES (%s, %s, %f, %f, %s)',
            $request->body()->all('pickup_points[*].code'),
            $request->body()->all('pickup_points[*].name'),
            $request->body()->all('pickup_points[*].latitude'),
            $request->body()->all('pickup_points[*].longitude'),
            array_map(
                $this->json->encode(...),
                $request->body()->all('pickup_points[*].sizes'),
            ),
        );

        return $this->responseBuilder
            ->statusCode(204);
    }
}
readonly class UpdateMinimumAgeAction implements UpdateMinimumAgeActionInterface
{
    public function __invoke(
        RequestInterface $request,
        int $termId,
        int $termTaxonomyId,
        array $args,
    ): void {
        update_term_meta(
            $termId,
            'minimum_age',
            MinimumAge::from($request->body()->get('minimum_age'))->value,
        );
    }
}
readonly class UpdateWholesalePriceAction implements UpdateWholesalePriceActionInterface
{
    public function __invoke(
        RequestInterface $request,
        int $postId,
        WP_Post $post,
    ): void {
        update_post_meta(
            $postId,
            'wholesale_price',
            $request->body()->get('wholesale_price'),
        );
    }
}
return routes()
    ->adminAjax(
        'cart_summary',
        action(GetCartSummaryActionInterface::class),
    );
readonly class GetCartSummaryAction implements GetCartSummaryActionInterface
{
    public function __construct(
        protected ResponsesBuilderInterface $responsesBuilder,
    ) {
    }

    public function __invoke(RequestInterface $request): ResponsesBuilderInterface
    {
        $cartSummaryView = new CartSummaryView(
            WC()->cart->get_cart_contents_count(),
            (float) WC()->cart->get_total('edit'),
        );

        return $this->responsesBuilder
            ->statusCode(200)
            ->body($cartSummaryView, 'text/html', 'application/json');
    }
}
readonly class CartSummaryView implements RenderableInterface
{
    public function __construct(
        public int $count,
        public float $total,
    ) {
    }

    public function name(): string
    {
        return 'cart-summary';
    }
}
<a class="cart-summary" href="<?= $escaper->url(wc_get_cart_url()) ?>">
    <span class="cart-summary__count"><?= $escaper->html((string) $view->count) ?></span>
    <?= wc_price($view->total) ?>
</a>

initial commit

2026-02-27

1.0.0-alpha.1

2026-12-31 — first release: HTTP layer, hooks, routes, middleware, data access, migrations, caching, rendering, exception handling, localization and logging.