diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 15b1e927e5c..ef2934e9aa3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -55,7 +55,7 @@ jobs: - name: Install js dependencies run: yarn --frozen-lockfile - - run: 'yarn lint --max-warnings 44 > /dev/null' + - run: 'yarn lint --max-warnings 43 > /dev/null' - run: yarn pretty diff --git a/app/Http/Controllers/Store/CheckoutController.php b/app/Http/Controllers/Store/CheckoutController.php index 8f708a621a7..38bd3760cc9 100644 --- a/app/Http/Controllers/Store/CheckoutController.php +++ b/app/Http/Controllers/Store/CheckoutController.php @@ -62,8 +62,9 @@ public function store() return ujs_redirect(route('store.cart.show')); } - if ($params['hide_from_activity'] !== null) { - $order->setGiftsHidden($params['hide_from_activity']); + $hideFromActivity = $params['hide_from_activity']; + if ($hideFromActivity) { + $order->setGiftsHidden($hideFromActivity); } $checkout = new OrderCheckout($order, $params['provider'], $params['shopifyCheckoutId']); diff --git a/resources/js/core-legacy/store-checkout.coffee b/resources/js/core-legacy/store-checkout.coffee deleted file mode 100644 index 64b2a068bff..00000000000 --- a/resources/js/core-legacy/store-checkout.coffee +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -# See the LICENCE file in the repository root for full licence text. - -# TODO: migrate to store.ts. - -import { route } from 'laroute' -import { StorePaypal } from 'store-paypal' -import { StoreXsolla } from 'store-xsolla' -import { onError } from 'utils/ajax' -import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay' - -export class StoreCheckout - @CHECKOUT_SELECTOR: '.js-store-checkout-button' - - @initialize: => - # load scripts - init = {} - - for element in document.querySelectorAll(@CHECKOUT_SELECTOR) - provider = element.dataset.provider - orderNumber = element.dataset.orderNumber - switch provider - when 'free' then init['free'] = Promise.resolve() - when 'paypal' then init['paypal'] = Promise.resolve() - when 'xsolla' then init['xsolla'] = StoreXsolla.promiseInit(orderNumber) - - $(@CHECKOUT_SELECTOR).on 'click.checkout', (event) => - { orderId, provider } = event.target.dataset - # sanity - return unless provider? - showLoadingOverlay() - showLoadingOverlay.flush() - - init[provider]?.then -> - hide_from_activity = document.querySelector('.js-hide-from-activity')?.checked - $.post(route('store.checkout.store'), { hide_from_activity, provider, orderId }) - .then => - @startPayment(event.target.dataset) - .catch @handleError - - - @startPayment: (params) -> - { orderId, provider, url } = params - switch provider - when 'paypal' - StorePaypal.fetchApprovalLink(orderId).then (link) -> - window.location.href = link - - when 'xsolla' - new Promise (resolve) -> - # FIXME: flickering when transitioning to widget - XPayStationWidget.open() - hideLoadingOverlay() - resolve() - - - @handleError: (error) -> - hideLoadingOverlay() - # errors from they jquery deferred will propagate here. - if error.getResponseHeader # check if 4xx ujs_redirect - type = error.getResponseHeader('Content-Type') - return if _.startsWith(type, 'application/javascript') - - # TODO: less unknown error, disable button - # TODO: handle error.message - onError(error?.xhr) diff --git a/resources/js/entrypoints/store-bootstrap.ts b/resources/js/entrypoints/store-bootstrap.ts index 8b3d8432cc3..a8cc0232615 100644 --- a/resources/js/entrypoints/store-bootstrap.ts +++ b/resources/js/entrypoints/store-bootstrap.ts @@ -1,6 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { Store } from 'store'; +import Store from 'store'; -Store.init(window); +Store.init(); diff --git a/resources/js/main.coffee b/resources/js/main.coffee index 54130a80058..e9aac9d18a4 100644 --- a/resources/js/main.coffee +++ b/resources/js/main.coffee @@ -24,7 +24,6 @@ import Menu from 'core-legacy/menu' import NavButton from 'core-legacy/nav-button' import Nav2 from 'core-legacy/nav2' import Search from 'core-legacy/search' -import { StoreCheckout } from 'core-legacy/store-checkout' import TooltipDefault from 'core-legacy/tooltip-default' import { navigate } from 'utils/turbolinks' @@ -39,7 +38,6 @@ jQuery.timeago.inWords = (distanceMillis) -> $(document).on 'turbo:load', -> BeatmapPack.initialize() - StoreCheckout.initialize() window.bbcodePreview ?= new BbcodePreview window.checkboxValidation ?= new CheckboxValidation diff --git a/resources/js/shopify-client.ts b/resources/js/shopify-client.ts deleted file mode 100644 index 173a5decc05..00000000000 --- a/resources/js/shopify-client.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -import { createStorefrontApiClient } from '@shopify/storefront-api-client'; -import { parseJson } from 'utils/json'; - -interface ShopifyStorefrontOptions { - apiVersion: string; - publicAccessToken: string; - storeDomain: string; -} - -export default function storefrontClient() { - const options = parseJson('json-shopify-storefront-options'); - - return createStorefrontApiClient(options); -} diff --git a/resources/js/shopify-gid.ts b/resources/js/shopify-gid.ts deleted file mode 100644 index 94219bea4f1..00000000000 --- a/resources/js/shopify-gid.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -export function toShopifyVariantGid(id?: string) { - return btoa(`gid://shopify/ProductVariant/${id}`); -} diff --git a/resources/js/store-paypal.coffee b/resources/js/store-paypal.coffee deleted file mode 100644 index 9504c666005..00000000000 --- a/resources/js/store-paypal.coffee +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -# See the LICENCE file in the repository root for full licence text. - -import { route } from 'laroute' - -export class StorePaypal - @fetchApprovalLink: (orderId) -> - new Promise (resolve, reject) -> - $.post route('payments.paypal.create'), order_id: orderId - .done resolve - .fail (xhr) -> - reject(xhr: xhr) diff --git a/resources/js/store-xsolla.coffee b/resources/js/store-xsolla.coffee deleted file mode 100644 index e1a7eed881e..00000000000 --- a/resources/js/store-xsolla.coffee +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -# See the LICENCE file in the repository root for full licence text. - -import { route } from 'laroute' -import core from 'osu-core-singleton' -import { showLoadingOverlay } from 'utils/loading-overlay' - -export class StoreXsolla - @promiseInit: (orderNumber) -> - Promise.all([ - StoreXsolla.fetchToken(orderNumber), StoreXsolla.fetchScript() - ]).then (values) -> - StoreXsolla.onXsollaReady(orderNumber) - XPayStationWidget.init(values[0]) - - - @fetchScript: -> - core.turbolinksReload.load('https://static.xsolla.com/embed/paystation/1.0.7/widget.min.js') - - - @fetchToken: (orderNumber) -> - new Promise (resolve, reject) -> - $.post route('payments.xsolla.token'), { orderNumber } - .done (data) -> - resolve(data) - .fail (xhr) -> - reject(xhr: xhr) - - - @onXsollaReady: (orderNumber) -> - done = false - - XPayStationWidget.on XPayStationWidget.eventTypes.STATUS_DONE, -> - done = true - - XPayStationWidget.on XPayStationWidget.eventTypes.CLOSE, -> - if done - showLoadingOverlay() - showLoadingOverlay.flush() - window.location = route('payments.xsolla.completed', 'foreignInvoice': orderNumber) diff --git a/resources/js/store.ts b/resources/js/store.ts deleted file mode 100644 index 1d6085ea90c..00000000000 --- a/resources/js/store.ts +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -import { Cart, CartCreatePayload } from '@shopify/hydrogen-react/storefront-api-types'; -import { route } from 'laroute'; -import core from 'osu-core-singleton'; -import { toShopifyVariantGid } from 'shopify-gid'; -import { createClickCallback } from 'utils/html'; -import { trans } from 'utils/lang'; -import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; -import { popup } from 'utils/popup'; -import storefrontClient from './shopify-client'; - -declare global { - interface Window { - Store?: Store; - } -} - -type ClickEvent = JQuery.ClickEvent; - -export class Store { - private constructor() { - $(document).on('click', '.js-store-checkout', (event: ClickEvent) => void this.beginCheckout(event)); - $(document).on('click', '.js-store-resume-checkout', (event: ClickEvent) => this.resumeCheckout(event)); - - $(document).on('turbo:load', () => { - $('.js-store-checkout').prop('disabled', false); - }); - - $('.js-store-checkout').prop('disabled', false); - } - - static init(sharedContext: Window) { - sharedContext.Store = sharedContext.Store ?? new Store(); - } - - async beginCheckout(event: ClickEvent) { - if (event.target == null) return; - - const dataset = event.target.dataset; - const orderId = dataset.orderId; - const shouldShopify = dataset.shopify === '1'; - if (orderId == null) { - throw new Error('orderId is missing'); - } - - if (shouldShopify) { - try { - await this.beginShopifyCheckout(orderId); - } catch (error) { - hideLoadingOverlay(); - core.userVerification.showOnError(error, createClickCallback(event.target)); - } - - return; - } - - Turbo.visit(route('store.checkout.show', { checkout: orderId })); - } - - async beginShopifyCheckout(orderId: string) { - showLoadingOverlay(); - showLoadingOverlay.flush(); - - const operation = ` - mutation CreateCart($input: CartInput) { - cartCreate(input: $input) { - cart { - id - checkoutUrl - lines(first: 10) { - edges { - node { - id - merchandise { - ... on ProductVariant { - id - title - } - } - } - } - } - cost { - totalAmount { - amount - currencyCode - } - } - } - } - } - `; - - // create shopify checkout. - // error returned will be a JSON string in error.message - const response = await storefrontClient().request(operation, { variables: { input: this.shopifyCartInput(orderId) } }); - const data = response.data as { cartCreate: CartCreatePayload }; - - if (response.errors != null || data.cartCreate.cart == null) { - hideLoadingOverlay(); - popup(trans('errors.checkout.generic'), 'danger'); - return; - } - - const params = { - orderId, - provider: 'shopify', - shopifyCheckoutId: data.cartCreate.cart.id, - }; - - await $.post(route('store.checkout.store'), params); - window.location.href = data.cartCreate.cart.checkoutUrl; - } - - resumeCheckout(event: ClickEvent) { - if (event.target == null) return; - - const target = event.target; - const { provider, providerReference, shopifyUrl, status } = target.dataset; - - // TODO: replace the links with just links... - if (provider === 'shopify' && status !== 'cancelled') { - if (shopifyUrl != null) { - window.location.href = shopifyUrl; - } else if (providerReference != null) { - this.resumeShopifyCheckout(providerReference); - } else { - // TODO: show error. - } - } else { - Turbo.visit(route('store.invoice.show', { invoice: target.dataset.orderId })); - } - } - - async resumeShopifyCheckout(cartId: string) { - showLoadingOverlay(); - showLoadingOverlay.flush(); - - const operation = ` - query ($cartId: ID!) { - cart(id: $cartId) { - id - checkoutUrl - attributes { - key - value - } - } - } - `; - - const response = await storefrontClient().request(operation, { variables: { cartId } }); - const data = response.data as { cart?: Cart }; - - if (response.errors != null || data.cart == null) { - hideLoadingOverlay(); - popup(trans('errors.checkout.generic'), 'danger'); - return; - } - - if (data.cart == null) { - popup(trans('store.order.shopify_expired'), 'info'); - hideLoadingOverlay(); - } else { - window.location.href = data.cart.checkoutUrl; - } - } - - private collectShopifyCartLines() { - return $('.js-store-order-item').map((_, element) => ({ - merchandiseId: toShopifyVariantGid(element.dataset.shopifyId), - quantity: Number(element.dataset.quantity), - })).get(); - } - - private shopifyCartInput(orderId: string) { - return { - attributes: [{ key: 'orderId', value: orderId }], - lines: this.collectShopifyCartLines(), - }; - } -} diff --git a/resources/js/store/index.ts b/resources/js/store/index.ts new file mode 100644 index 00000000000..fbcd87c6817 --- /dev/null +++ b/resources/js/store/index.ts @@ -0,0 +1,157 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { Cart, CartCreatePayload } from '@shopify/hydrogen-react/storefront-api-types'; +import { route } from 'laroute'; +import { error, isJqXHR, onError } from 'utils/ajax'; +import { createClickCallback } from 'utils/html'; +import { trans } from 'utils/lang'; +import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; +import { popup } from 'utils/popup'; +import { fetchApprovalLink } from './store-paypal'; +import { createShopifyCart, getShopifyCart } from './store-shopify'; +import { initXsolla } from './store-xsolla'; + +declare global { + interface Window { + Store?: Store; + } +} + +type TriggeredEvent = JQuery.TriggeredEvent; + +export default class Store { + private constructor() { + $(document).on('click.store', '.js-store-checkout', (event: TriggeredEvent) => void this.beginCheckout(event)); + $(document).on('click.store', '.js-store-resume-checkout', (event: TriggeredEvent) => this.resumeCheckout(event)); + $(document).on('click.store', '.js-store-payment-button', (event: TriggeredEvent) => void this.handlePaymentClick(event)); + + $(document).on('turbo:load', () => { + $('.js-store-checkout').prop('disabled', false); + }); + + $('.js-store-checkout').prop('disabled', false); + } + + static init() { + window.Store ??= new Store(); + } + + private async beginCheckout(event: TriggeredEvent) { + if (event.target == null) return; + + const dataset = event.target.dataset; + const orderId = dataset.orderId; + const shouldShopify = dataset.shopify === '1'; + if (orderId == null) { + throw new Error('orderId is missing'); + } + + if (!shouldShopify) { + Turbo.visit(route('store.checkout.show', { checkout: orderId })); + return; + } + + try { + await this.beginShopifyCheckout(orderId); + } catch (err) { + hideLoadingOverlay(); + if (!isJqXHR(err)) throw err; + error(err, err.statusText, createClickCallback(event.target)); + } + } + + private async beginShopifyCheckout(orderId: string) { + showLoadingOverlay(); + showLoadingOverlay.flush(); + // create shopify checkout. + // error returned will be a JSON string in error.message + const response = await createShopifyCart(orderId, Array.from(document.querySelectorAll('.js-store-order-item'))); + const data = response.data as { cartCreate: CartCreatePayload }; + + if (response.errors != null || data.cartCreate.cart == null) { + hideLoadingOverlay(); + popup(trans('errors.checkout.generic'), 'danger'); + return; + } + + const params = { + orderId, + provider: 'shopify', + shopifyCheckoutId: data.cartCreate.cart.id, + }; + + await $.post(route('store.checkout.store'), params); + window.location.href = data.cartCreate.cart.checkoutUrl; + } + + private async handlePaymentClick(event: TriggeredEvent) { + const { orderId, orderNumber, provider } = event.target.dataset; + // sanity + if (provider == null || orderId == null) throw new Error(); + showLoadingOverlay(); + showLoadingOverlay.flush(); + + if (provider === 'xsolla') { + if (orderNumber == null) throw new Error('missing orderNumber'); + await initXsolla(orderNumber); + } + + const hide_from_activity = document.querySelector('.js-hide-from-activity')?.checked; + await $.post(route('store.checkout.store'), { hide_from_activity, orderId, provider }); + try { + switch (provider) { + case 'paypal': { + const link = await fetchApprovalLink(orderId); + window.location.href = link; + break; + } + case 'xsolla': + // FIXME: flickering when transitioning to widget + window.XPayStationWidget.open(); + hideLoadingOverlay(); + break; + } + } catch (err) { + hideLoadingOverlay(); + if (!isJqXHR(err)) { + popup(trans('errors.unknown'), 'danger'); + return; + } + + if (err.getResponseHeader('content-type') === 'application/javascript') { + return; + } + + // TODO: less unknown error, disable button + // TODO: handle error.message + onError(err); + } + } + + private resumeCheckout(event: TriggeredEvent) { + const cartId = event.target.dataset.providerReference; + if (cartId == null) throw new Error('cartId is missing'); + this.resumeShopifyCheckout(cartId); + } + + private async resumeShopifyCheckout(cartId: string) { + showLoadingOverlay(); + showLoadingOverlay.flush(); + const response = await getShopifyCart(cartId); + const data = response.data as { cart?: Cart }; + + if (response.errors != null || data.cart == null) { + hideLoadingOverlay(); + popup(trans('errors.checkout.generic'), 'danger'); + return; + } + + if (data.cart == null) { + popup(trans('store.order.shopify_expired'), 'info'); + hideLoadingOverlay(); + } else { + window.location.href = data.cart.checkoutUrl; + } + } +} diff --git a/resources/js/store/store-paypal.ts b/resources/js/store/store-paypal.ts new file mode 100644 index 00000000000..661de781c5b --- /dev/null +++ b/resources/js/store/store-paypal.ts @@ -0,0 +1,8 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { route } from 'laroute'; + +export function fetchApprovalLink(orderId: string) { + return $.post(route('payments.paypal.create'), { order_id: orderId }) as JQuery.jqXHR; +} diff --git a/resources/js/store/store-shopify.ts b/resources/js/store/store-shopify.ts new file mode 100644 index 00000000000..74eb23aee74 --- /dev/null +++ b/resources/js/store/store-shopify.ts @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { createStorefrontApiClient } from '@shopify/storefront-api-client'; +import { fail } from 'utils/fail'; +import { parseJson } from 'utils/json'; +import { present } from 'utils/string'; + +interface ShopifyStorefrontOptions { + apiVersion: string; + publicAccessToken: string; + storeDomain: string; +} + +const createCartGraphql = ` + mutation CreateCart($input: CartInput) { + cartCreate(input: $input) { + cart { + id + checkoutUrl + lines(first: 10) { + edges { + node { + id + merchandise { + ... on ProductVariant { + id + title + } + } + } + } + } + cost { + totalAmount { + amount + currencyCode + } + } + } + } + } +`; + +const getCartGraphql = ` + query ($cartId: ID!) { + cart(id: $cartId) { + id + checkoutUrl + attributes { + key + value + } + } + } +`; + +export function createShopifyCart(orderId: string, elements: HTMLElement[]) { + const input = { + attributes: [{ key: 'orderId', value: orderId }], + lines: elements.map((element) => ({ + merchandiseId: toShopifyVariantGid(element.dataset.shopifyId), + quantity: Number(element.dataset.quantity), + })), + }; + + return storefrontClient().request(createCartGraphql, { variables: { input } }); +} + +export function getShopifyCart(cartId: string) { + return storefrontClient().request(getCartGraphql, { variables: { cartId } }); +} + +export function storefrontClient() { + const options = parseJson('json-shopify-storefront-options'); + + return createStorefrontApiClient(options); +} + +function toShopifyVariantGid(id?: string) { + return present(id) ? btoa(`gid://shopify/ProductVariant/${id}`) : fail('missing variant id'); +} diff --git a/resources/js/store/store-xsolla.ts b/resources/js/store/store-xsolla.ts new file mode 100644 index 00000000000..504f50ff331 --- /dev/null +++ b/resources/js/store/store-xsolla.ts @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { route } from 'laroute'; +import core from 'osu-core-singleton'; +import { showLoadingOverlay } from 'utils/loading-overlay'; + +declare global { + interface Window { + XPayStationWidget: { + eventTypes: { + CLOSE: string; + STATUS_DONE: string; + }; + init: (config: XsollaTokenResponse) => void; + on: (event: string, callback: () => void) => void; + open(): void; + }; + } +} + +interface XsollaTokenResponse { + access_token: string; + sandbox: boolean; +} + +const xsollaWidgetUrl = 'https://cdn.xsolla.net/payments-bucket-prod/embed/1.5.4/widget.min.js'; + +function onXsollaReady(orderNumber: string) { + let done = false; + window.XPayStationWidget.on(window.XPayStationWidget.eventTypes.STATUS_DONE, () => done = true); + window.XPayStationWidget.on(window.XPayStationWidget.eventTypes.CLOSE, () => { + if (done) { + showLoadingOverlay(); + showLoadingOverlay.flush(); + window.location.href = route('payments.xsolla.completed', { foreignInvoice: orderNumber }); + } + }); +} + +export async function initXsolla(orderNumber: string) { + const [tokenResponse] = await Promise.all([ + $.post(route('payments.xsolla.token'), { orderNumber }) as JQuery.jqXHR, + core.turbolinksReload.load(xsollaWidgetUrl), + ]); + onXsollaReady(orderNumber); + window.XPayStationWidget.init(tokenResponse); +} diff --git a/resources/lang/en/store.php b/resources/lang/en/store.php index 76ef425571e..3f746025731 100644 --- a/resources/lang/en/store.php +++ b/resources/lang/en/store.php @@ -113,6 +113,7 @@ 'shopify_expired' => 'The checkout link for this order has expired.', 'subtotal' => 'Subtotal', 'total' => 'Total', + 'unavailable' => 'No additional information available for this order.', 'details' => [ 'order_number' => 'Order #', diff --git a/resources/views/store/checkout/_free.blade.php b/resources/views/store/checkout/_free.blade.php index 89f8a80ff11..de7bc35e19a 100644 --- a/resources/views/store/checkout/_free.blade.php +++ b/resources/views/store/checkout/_free.blade.php @@ -4,7 +4,7 @@ --}}
+ @elseif ($order->isCancelled()) + {{-- Show generic invoice for cancelled shopify orders with no shopify information --}} + + {{ osu_trans('store.order.invoice') }} + @else - {{-- TODO: remove after legacy carts migrated/check failed migration --}} + {{-- Shouldn't get to here normally, but dev can be in a weird state --}} @endif @elseif ($order->hasInvoice())