diff --git a/commera/api/payments.py b/commera/api/payments.py index 85fe0d5f..e8cbcea3 100644 --- a/commera/api/payments.py +++ b/commera/api/payments.py @@ -765,7 +765,10 @@ def apply_coupon_code(applied_code: str): validate_cart_is_not_in_checkout(quotation.name) quotation.coupon_code = coupon_name save_cart_quotation(quotation) - return {"message": _("Coupon code applied successfully")} + return { + "message": _("Coupon code applied successfully"), + "checkout_summary": get_checkout_summary(quotation), + } # Guest checkout: refuses any caller without a guest cart cookie, and is rate limited. diff --git a/commera/hooks.py b/commera/hooks.py index 74ca66ba..34fa6408 100644 --- a/commera/hooks.py +++ b/commera/hooks.py @@ -28,6 +28,7 @@ website_route_rules = [ # SPA client-side routing: every /commera deep link resolves to the same shell so a reload does not 404. {"from_route": "/commera/", "to_route": "/commera"}, + {"from_route": "/cart/", "to_route": "/cart/permalink.html"}, # ------------ # English Routes # ------------ @@ -40,6 +41,7 @@ # -> Cart / Checkout {"from_route": "/en/cart", "to_route": "/cart/cart.html"}, {"from_route": "/en/cart/checkout", "to_route": "/cart/checkout.html"}, + {"from_route": "/en/cart/", "to_route": "/cart/permalink.html"}, # -> Account {"from_route": "/en/account/dashboard", "to_route": "/account/dashboard.html"}, {"from_route": "/en/account/profile", "to_route": "/account/profile.html"}, @@ -67,6 +69,7 @@ # -> Cart / Checkout {"from_route": "/ar/cart", "to_route": "/cart/cart.html"}, {"from_route": "/ar/cart/checkout", "to_route": "/cart/checkout.html"}, + {"from_route": "/ar/cart/", "to_route": "/cart/permalink.html"}, # -> Account {"from_route": "/ar/account/dashboard", "to_route": "/account/dashboard.html"}, {"from_route": "/ar/account/profile", "to_route": "/account/profile.html"}, diff --git a/commera/shop_themes/doctype/shop_theme_settings/shop_theme_settings.py b/commera/shop_themes/doctype/shop_theme_settings/shop_theme_settings.py index f4648086..ce1454c1 100644 --- a/commera/shop_themes/doctype/shop_theme_settings/shop_theme_settings.py +++ b/commera/shop_themes/doctype/shop_theme_settings/shop_theme_settings.py @@ -28,6 +28,11 @@ "template_path": "pages/cart/checkout.html", "requires_auth": 0, }, + { + "url_pattern": rf"^{LANG}/cart/(?!checkout$)(?P.+)$", + "template_path": "pages/cart/permalink.html", + "requires_auth": 0, + }, { "url_pattern": rf"^{LANG}/account/dashboard$", "template_path": "pages/account/dashboard.html", diff --git a/commera/templates/includes/cart-permalink.html b/commera/templates/includes/cart-permalink.html new file mode 100644 index 00000000..957c70c4 --- /dev/null +++ b/commera/templates/includes/cart-permalink.html @@ -0,0 +1,16 @@ + diff --git a/commera/templates/includes/cart-state.html b/commera/templates/includes/cart-state.html index 4ca1ce2d..e20d7a8f 100644 --- a/commera/templates/includes/cart-state.html +++ b/commera/templates/includes/cart-state.html @@ -145,6 +145,25 @@ remove_selected() { this.items = this.items.filter(item => !item.selected); }, + link(discount_code = "") { + const pairs = this.items + .filter(item => item.selected) + .map(item => `${encodeURIComponent(item.variant.item_code)}:${item.qty}`); + const language = window.location.pathname.split("/")[1] === "ar" ? "ar" : "en"; + const url = new URL(`/${language}/cart/${pairs.join(",")}`, window.location.origin); + if (discount_code) url.searchParams.set("discount", discount_code); + return url.href; + }, + async copy_link(discount_code = "") { + const url = this.link(discount_code); + try { + await navigator.clipboard.writeText(url); + toast({{ _("Cart link copied") | tojson }}, { type: "success" }); + } catch (error) { + // The clipboard is refused over plain http and in some in-app browsers. + window.prompt({{ _("Copy this cart link") | tojson }}, url); + } + }, async update_variant(cart_item, selected_size) { const item_name = cart_item.item.name; const reponse = await frappe_call("/api/v2/method/commera.api.cart.update_variant", { product_name: item_name, size: selected_size }); diff --git a/commera/templates/partials/cart/body.html b/commera/templates/partials/cart/body.html index 446a31e3..82221f63 100644 --- a/commera/templates/partials/cart/body.html +++ b/commera/templates/partials/cart/body.html @@ -11,7 +11,7 @@ } frappe_call("/api/v2/method/commera.api.payments.generate_quotation_for_cart", { cart: { items: selected_items } }) .then((d) => { - window.location.href = "/{{frappe.lang}}/cart/checkout" + window.location.href = "/{{frappe.lang}}/cart/checkout" + window.location.search }).finally(() => { state.checkout_loading = false; }) @@ -258,6 +258,10 @@

{% endif %} + diff --git a/commera/templates/partials/checkout/body.html b/commera/templates/partials/checkout/body.html index 841bb52c..68dc2fc3 100644 --- a/commera/templates/partials/checkout/body.html +++ b/commera/templates/partials/checkout/body.html @@ -37,6 +37,19 @@ }) this.apply_shipping_rule() this.validate_payment_mode() + this.apply_pending_discount() + }, + async apply_pending_discount() { + const url = new URL(window.location.href); + const discount = url.searchParams.get("discount"); + if (!discount) return; + // Stripped first: removing the coupon reloads this page and must not re-apply it. + url.searchParams.delete("discount"); + history.replaceState(null, "", url); + try { + await frappe_call("/api/v2/method/commera.api.payments.apply_coupon_code", { applied_code: discount }); + window.location.reload(); + } catch (error) {} }, calculate_checkout_total() { @@ -72,6 +85,12 @@ }) + function fill_empty_fields(form, prefill) { + for (const [field, value] of Object.entries(prefill)) { + const input = form.elements[`billing_${field}`]; + if (input && !input.value) input.value = value; + } + } function billing_state() { return { billing_use_address: 'saved_address', @@ -247,8 +266,14 @@ x-init="$watch('payment_mode_confirmed', value => fetch_payment_details(value, $data))">

{{ _('Checkout') }}

-
- {% include 'templates/components/promo_code_component.html' %} +
+
+ {% include 'templates/components/promo_code_component.html' %} +
+
@@ -273,7 +298,8 @@

-
+

{{ _('select delivery option') }} diff --git a/commera/tests/test_cart_checkout.py b/commera/tests/test_cart_checkout.py index c871e1a3..3a8f581f 100644 --- a/commera/tests/test_cart_checkout.py +++ b/commera/tests/test_cart_checkout.py @@ -12,6 +12,7 @@ from commera.api.payments import ( COD_PAYMENT_MODE, CheckoutPriceChangedError, + apply_coupon_code, confirm_payment, generate_quotation_for_cart, initiate_checkout_with_mode, @@ -351,6 +352,43 @@ def test_a_grand_total_coupon_shows_as_a_discount_the_lines_add_up_to(self): self.assertEqual(summary["total"], round(270 + 48.6 + summary["shipping"] - 50)) self.assert_summary_adds_up(summary) + def create_coupon(self, discount_percentage: float) -> str: + pricing_rule = frappe.get_doc( + { + "doctype": "Pricing Rule", + "title": f"ZZ Coupon {frappe.generate_hash(length=8)}", + "apply_on": "Transaction", + "rate_or_discount": "Discount Percentage", + "discount_percentage": discount_percentage, + "selling": 1, + "coupon_code_based": 1, + "currency": frappe.get_cached_value("Price List", self.sale_price_list, "currency"), + } + ).insert(ignore_permissions=True) + coupon_code = f"ZZ{frappe.generate_hash(length=8).upper()}" + frappe.get_doc( + { + "doctype": "Coupon Code", + "coupon_name": coupon_code, + "coupon_code": coupon_code, + "coupon_type": "Promotional", + "pricing_rule": pricing_rule.name, + } + ).insert(ignore_permissions=True) + return coupon_code + + def test_applying_a_coupon_returns_the_discounted_checkout_summary(self): + coupon_code = self.create_coupon(discount_percentage=10) + frappe.set_user(self.shopper) + generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 3)]}) + self.assertEqual(get_checkout_summary(_get_cart_quotation())["discount_amount"], 0) + + summary = apply_coupon_code(coupon_code)["checkout_summary"] + + self.assertEqual(summary["subtotal"], 270) + self.assertAlmostEqual(summary["discount_amount"], (270 + summary["shipping"]) * 0.1, places=2) + self.assert_summary_adds_up(summary) + def test_the_cod_checkout_total_is_what_the_order_charges(self): ensure_fiscal_year() frappe.set_user(self.shopper) diff --git a/commera/tests/test_cart_permalink.py b/commera/tests/test_cart_permalink.py new file mode 100644 index 00000000..862e2491 --- /dev/null +++ b/commera/tests/test_cart_permalink.py @@ -0,0 +1,276 @@ +import re +from urllib.parse import parse_qsl, urlsplit + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.website.page_renderers.redirect_page import RedirectPage +from frappe.website.path_resolver import PathResolver +from frappe.website.serve import get_response_content +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request + +from commera.shop_themes.doctype.shop_theme_settings.shop_theme_settings import DEFAULT_ROUTES +from commera.tests.test_product_details import ProductDetailPriceTestCase +from commera.utils import get_country_list +from commera.www.cart import permalink +from commera.www.cart.checkout import get_checkout_prefill +from commera.www.cart.checkout import get_context as get_checkout_context + + +class TestParseCartCodes(IntegrationTestCase): + def test_quantities_merge_and_missing_qty_means_one(self): + quantities, notices = permalink.parse_cart_codes("SHIRT-M:2,CAP,SHIRT-M:1") + + self.assertEqual(quantities, {"SHIRT-M": 3, "CAP": 1}) + self.assertEqual(notices, []) + + def test_item_code_keeps_everything_before_the_last_colon(self): + quantities, _ = permalink.parse_cart_codes("A:B:4") + + self.assertEqual(quantities, {"A:B": 4}) + + def test_non_positive_or_fractional_quantities_are_skipped_with_a_notice(self): + quantities, notices = permalink.parse_cart_codes("ZERO:0,NEGATIVE:-1,HALF:1.5,WORD:two,:3,OK:1") + + self.assertEqual(quantities, {"OK": 1}) + self.assertEqual(len(notices), 5) + + def test_lines_beyond_the_cap_are_skipped_under_one_notice(self): + codes = ",".join(f"ITEM-{index}:1" for index in range(1000)) + quantities, notices = permalink.parse_cart_codes(codes) + + self.assertEqual(len(quantities), permalink.MAX_CART_LINES) + self.assertNotIn(f"ITEM-{permalink.MAX_CART_LINES}", quantities) + self.assertEqual(len(notices), 1) + self.assertIn(str(1000 - permalink.MAX_CART_LINES), notices[0]) + + +class TestCartPermalinkPage(ProductDetailPriceTestCase): + def setUp(self): + super().setUp() + self.addCleanup(frappe.clear_document_cache, "Commera Settings", "Commera Settings") + self.addCleanup(frappe.set_user, "Administrator") + self.medium = self.make_stocked_item("M", stock_qty=5, default_rate=100, sale_rate=80) + self.large = self.make_stocked_item("L", stock_qty=0, default_rate=100) + self.small = self.make_stocked_item("S", stock_qty=2, default_rate=100) + self.variant = self.make_sized_variant({"M": self.medium, "L": self.large, "S": self.small}) + + def make_stocked_item(self, label, stock_qty, default_rate, sale_rate=None): + item_code = self.make_item(label) + self.make_item_price(item_code, self.default_price_list, default_rate) + if sale_rate is not None: + self.make_item_price(item_code, self.sale_price_list, sale_rate) + if stock_qty: + frappe.get_doc( + { + "doctype": "Bin", + "item_code": item_code, + "warehouse": self.warehouse, + "actual_qty": stock_qty, + } + ).insert(ignore_permissions=True) + return item_code + + def make_sized_variant(self, item_code_by_size): + variant = frappe.new_doc("Style Attribute Variant") + variant.configurator = self.configurator + variant.item_style = self.medium + variant.item_group = self.item_group + variant.attribute_value = f"Val {frappe.generate_hash(length=6)}" + variant.display_name = f"Permalink {self.suffix}" + variant.route = f"permalink-test-{self.suffix.lower()}" + variant.append("images", {"image": "/assets/permalink-front.jpg"}) + variant.append("images", {"image": "/assets/permalink-back.jpg"}) + for size, item_code in item_code_by_size.items(): + variant.append("sizes", {"size": size, "item_code": item_code}) + variant.is_published = 1 + variant.insert(ignore_permissions=True) + return variant + + def render(self, codes, **query): + self.addCleanup(setattr, frappe.local, "form_dict", frappe.local.form_dict) + frappe.local.form_dict = frappe._dict(codes=codes, **query) + context = frappe._dict() + permalink.get_context(context) + return context + + def test_line_matches_the_shape_the_cart_store_adds(self): + context = self.render(f"{self.medium}:2") + + self.assertEqual(context.notices, []) + [line] = context.lines + self.assertEqual(line["qty"], 2) + self.assertEqual(line["price"], 80) + self.assertEqual(line["default_price"], 100) + self.assertEqual(line["item"]["name"], self.variant.name) + self.assertEqual(line["item"]["route"], self.variant.route) + self.assertEqual( + [image["image"] for image in line["item"]["images"]], + ["/assets/permalink-front.jpg", "/assets/permalink-back.jpg"], + ) + self.assertEqual(line["variant"]["item_code"], self.medium) + self.assertEqual(line["variant"]["size"], "M") + self.assertEqual(line["variant"]["stock_detail"]["stock_qty"], 5) + self.assertEqual([size["size"] for size in line["sizes"]], ["S", "M", "L"]) + + def test_quantity_is_lowered_to_stock_with_a_notice(self): + context = self.render(f"{self.small}:9") + + self.assertEqual(context.lines[0]["qty"], 2) + self.assertEqual(len(context.notices), 1) + + def test_out_of_stock_unknown_and_disabled_codes_are_dropped(self): + frappe.db.set_value("Item", self.small, "disabled", 1) + context = self.render(f"{self.large}:1,NO-SUCH-ITEM-{self.suffix}:1,{self.small}:1,{self.medium}:1") + + self.assertEqual([line["variant"]["item_code"] for line in context.lines], [self.medium]) + self.assertEqual(len(context.notices), 3) + + def test_unpublished_product_is_not_added(self): + frappe.db.set_value("Style Attribute Variant", self.variant.name, "is_published", 0) + context = self.render(f"{self.medium}:1") + + self.assertEqual(context.lines, []) + self.assertEqual(context.next_url, "/en/cart") + + def test_link_with_nothing_to_add_says_so_instead_of_waiting(self): + html = get_response_content("en/cart/,,,") + + self.assertIn("This cart link has nothing to add", html) + self.assertNotIn("Adding the products to your bag", html) + + def test_signed_in_shopper_resumes_checkout_from_the_cart_keeping_campaign_params(self): + context = self.render( + f"{self.medium}:1", + discount="SUMMER10", + utm_source="newsletter", + ref="dropped", + **{"checkout[email]": "shopper@example.com"}, + ) + + next_url = urlsplit(context.next_url) + self.assertEqual(next_url.path, "/en/cart") + self.assertEqual( + dict(parse_qsl(next_url.query)), + { + "checkout": "1", + "discount": "SUMMER10", + "utm_source": "newsletter", + "checkout[email]": "shopper@example.com", + }, + ) + + def test_guest_goes_to_checkout_only_when_guest_checkout_is_on(self): + frappe.set_user("Guest") + for allow_guest_checkout, expected_path in ((1, "/en/cart/checkout"), (0, "/en/cart")): + frappe.db.set_single_value("Commera Settings", "allow_guest_checkout", allow_guest_checkout) + frappe.clear_document_cache("Commera Settings", "Commera Settings") + context = self.render(f"{self.medium}:1", discount="SUMMER10") + + self.assertEqual(urlsplit(context.next_url).path, expected_path) + self.assertEqual(dict(parse_qsl(urlsplit(context.next_url).query)), {"discount": "SUMMER10"}) + + +class TestBareCartLink(IntegrationTestCase): + def test_bare_link_redirects_to_english_keeping_item_codes_and_query_encoded(self): + frappe.local.request = Request( + EnvironBuilder( + path="/cart/Will%20You%20Commit%3F-OS:2", query_string="discount=A&utm_source=x" + ).get_environ() + ) + self.addCleanup(delattr, frappe.local, "request") + self.addCleanup(setattr, frappe.local, "form_dict", frappe.local.form_dict) + frappe.local.form_dict = frappe._dict(codes="Will You Commit?-OS:2") + + with self.assertRaises(frappe.Redirect): + permalink.get_context(frappe._dict()) + self.assertEqual( + frappe.flags.redirect_location, "/en/cart/Will%20You%20Commit%3F-OS:2?discount=A&utm_source=x" + ) + + +class TestGuestCheckoutRedirect(IntegrationTestCase): + def test_guest_sent_back_to_the_cart_keeps_the_query_string(self): + frappe.local.request = Request( + EnvironBuilder(path="/en/cart/checkout", query_string="discount=A&utm_source=x").get_environ() + ) + self.addCleanup(delattr, frappe.local, "request") + frappe.local.lang = "en" + frappe.set_user("Guest") + self.addCleanup(frappe.set_user, "Administrator") + + with self.assertRaises(frappe.Redirect): + get_checkout_context(frappe._dict()) + self.assertEqual(frappe.flags.redirect_location, "/en/cart?discount=A&utm_source=x") + + +class TestCartRoutes(IntegrationTestCase): + def resolve(self, path): + frappe.local.request = Request(EnvironBuilder(path=f"/{path}").get_environ()) + self.addCleanup(delattr, frappe.local, "request") + self.addCleanup(setattr, frappe.local, "form_dict", frappe.local.form_dict) + frappe.local.form_dict = frappe._dict() + return PathResolver(path).resolve() + + def test_checkout_is_not_taken_for_a_cart_link(self): + endpoint, _ = self.resolve("en/cart/checkout") + + self.assertEqual(endpoint, "/cart/checkout.html") + + def test_a_cart_link_resolves_to_the_permalink_page_with_its_codes(self): + endpoint, _ = self.resolve("en/cart/X:1,Y:2") + + self.assertEqual(endpoint, "/cart/permalink.html") + self.assertEqual(frappe.form_dict.codes, "X:1,Y:2") + + def test_bare_checkout_still_redirects_to_english(self): + _, renderer = self.resolve("cart/checkout") + + self.assertIsInstance(renderer, RedirectPage) + self.assertEqual(frappe.flags.redirect_location, "/en/cart/checkout") + + def test_theme_permalink_route_leaves_checkout_alone(self): + [pattern] = [ + re.compile(route["url_pattern"]) + for route in DEFAULT_ROUTES + if route["template_path"] == "pages/cart/permalink.html" + ] + + self.assertIsNone(pattern.match("en/cart/checkout")) + self.assertEqual(pattern.match("en/cart/X:1")["codes"], "X:1") + + +class TestCheckoutPrefill(IntegrationTestCase): + def prefill(self, **query): + self.addCleanup(setattr, frappe.local, "form_dict", frappe.local.form_dict) + frappe.local.form_dict = frappe._dict(query) + return get_checkout_prefill(get_country_list()) + + def test_shopify_params_map_onto_checkout_fields(self): + prefill = self.prefill( + **{ + "checkout[email]": " shopper@example.com ", + "checkout[shipping_address][address1]": "1 King Road", + "checkout[shipping_address][address2]": "Flat 4", + "checkout[shipping_address][province]": "Riyadh Province", + "checkout[shipping_address][zip]": "12345", + "checkout[shipping_address][country]": "SA", + "checkout[shipping_address][first_name]": "", + "checkout[note]": "ignored", + } + ) + + self.assertEqual( + prefill, + { + "email": "shopper@example.com", + "full_address": "1 King Road", + "landmark": "Flat 4", + "state": "Riyadh Province", + "po_box": "12345", + "country": "Saudi Arabia", + }, + ) + + def test_unknown_country_is_left_for_the_shopper(self): + self.assertEqual(self.prefill(**{"checkout[shipping_address][country]": "Atlantis"}), {}) diff --git a/commera/themes/summer_theme/pages/cart/cart.html b/commera/themes/summer_theme/pages/cart/cart.html index b8bd87ed..8f4e027e 100644 --- a/commera/themes/summer_theme/pages/cart/cart.html +++ b/commera/themes/summer_theme/pages/cart/cart.html @@ -149,6 +149,9 @@

{{ _("Delivery") }}
{{ _("Place Order") }} {% endif %} +
@@ -211,7 +214,7 @@
{{ _("Delivery") }}
await frappe_call("/api/v2/method/commera.api.payments.generate_quotation_for_cart", { cart: { items: items }, }); - window.location.href = "/{{ language }}/cart/checkout"; + window.location.href = "/{{ language }}/cart/checkout" + window.location.search; } finally { this.checkout_loading = false; } diff --git a/commera/themes/summer_theme/pages/cart/checkout.html b/commera/themes/summer_theme/pages/cart/checkout.html index f0ae5755..8a7cb5d0 100644 --- a/commera/themes/summer_theme/pages/cart/checkout.html +++ b/commera/themes/summer_theme/pages/cart/checkout.html @@ -14,6 +14,8 @@ "is_guest": is_guest, "delivery_charge": delivery_charge, "delivery_charge_applicable_below": delivery_charge_applicable_below, + "prefill": checkout_prefill, + "coupon_code": coupon_code, } %} {% block head %} @@ -475,6 +477,10 @@
{{ _("Payment Method") }}
{{ _("Placing...") }}
+ @@ -546,10 +552,24 @@

{{ _("Payment Method") }}
selected_shipping_option: null, shipping_option_saving: false, pending_shipping_option: null, + pending_discount: null, init() { this.set_available_payment_mode(); - if (!this.config.is_guest) return; + const url = new URL(window.location.href); + this.pending_discount = url.searchParams.get("discount"); + if (this.pending_discount) { + // Left in the URL, every reload would re-apply the coupon against its rate limit. + url.searchParams.delete("discount"); + history.replaceState(null, "", url); + } + for (const [field, value] of Object.entries(this.config.prefill)) { + if (!this.billing[field]) this.billing[field] = value; + } + if (!this.config.is_guest) { + this.apply_pending_discount(); + return; + } if (!this.$store.cart.items.length) { window.location.replace("/{{ language }}/cart"); return; @@ -572,6 +592,19 @@
{{ _("Payment Method") }}
return { ...summary, cash_on_delivery: summary }; }, + async apply_pending_discount() { + if (!this.pending_discount) return; + try { + const response = await frappe_call("/api/v2/method/commera.api.payments.apply_coupon_code", { + applied_code: this.pending_discount, + }); + toast(response.data.message, { type: "success" }); + this.summary = response.data.checkout_summary; + } catch (error) { + this.pending_discount = null; + } + }, + format_amount(amount) { return (Number(amount) || 0).toFixed(2); }, @@ -751,6 +784,8 @@
{{ _("Payment Method") }}
this.reset_address_step(); this.summary = response.data.checkout_summary; this.address_saved = true; + // A guest's cart is rebuilt on every address save, which drops any coupon it held. + if (this.config.is_guest) await this.apply_pending_discount(); await this.load_shipping_options(); } catch (error) { if (error.type === "InvalidEmailAddressError") this.email_error = INVALID_EMAIL_MESSAGE; diff --git a/commera/themes/summer_theme/pages/cart/permalink.html b/commera/themes/summer_theme/pages/cart/permalink.html new file mode 100644 index 00000000..d3e6dd92 --- /dev/null +++ b/commera/themes/summer_theme/pages/cart/permalink.html @@ -0,0 +1,21 @@ +{% extends "components/theme_layout.html" %} + +{% block uncontained_body %} + {% include "templates/includes/cart-permalink.html" %} +
+
+ {% if lines and not notices %} +

{{ _("Adding the products to your bag...") }}

+ {% else %} +

{{ _("Some products in this link were changed") if lines else _("This cart link has nothing to add") }}

+
    + {% for notice in notices %} +
  • {{ notice | e }}
  • + {% endfor %} +
+ {{ _("Continue") if lines else _("Go to your bag") }} + {% endif %} +
+
+{% endblock %} diff --git a/commera/themes/summer_theme/pages/cart/permalink.py b/commera/themes/summer_theme/pages/cart/permalink.py new file mode 100644 index 00000000..862539de --- /dev/null +++ b/commera/themes/summer_theme/pages/cart/permalink.py @@ -0,0 +1,6 @@ +from commera.www.cart import permalink + + +def get_context(context): + permalink.get_context(context) + return context diff --git a/commera/www/cart/checkout.py b/commera/www/cart/checkout.py index cf155997..ef17990a 100644 --- a/commera/www/cart/checkout.py +++ b/commera/www/cart/checkout.py @@ -1,6 +1,7 @@ import frappe from bwh_payments.bwh_payments.utils import get_available_payment_modes from frappe.query_builder import DocType +from frappe.utils.data import cstr from commera.api.shipping import get_checkout_summary from commera.core import _get_cart_quotation @@ -20,13 +21,28 @@ no_cache = True +CHECKOUT_PREFILL_PARAMS = { + "checkout[email]": "email", + "checkout[shipping_address][first_name]": "first_name", + "checkout[shipping_address][last_name]": "last_name", + "checkout[shipping_address][address1]": "full_address", + "checkout[shipping_address][address2]": "landmark", + "checkout[shipping_address][city]": "city", + "checkout[shipping_address][province]": "state", + "checkout[shipping_address][country]": "country", + "checkout[shipping_address][zip]": "po_box", +} + # @auth_required def get_context(context, allow_guest: bool = False): commera_settings = frappe.get_cached_doc("Commera Settings") if is_guest(): if not allow_guest: - frappe.redirect(f"/{frappe.local.lang}/cart") + cart_url = f"/{frappe.local.lang}/cart" + if frappe.request and (query_string := frappe.safe_decode(frappe.request.query_string)): + cart_url = f"{cart_url}?{query_string}" + frappe.redirect(cart_url) set_guest_cart_context(context) else: set_cart_context(context, commera_settings) @@ -34,6 +50,7 @@ def get_context(context, allow_guest: bool = False): context.payment_gateways = get_available_payment_modes() context.show_cod = commera_settings.get("cod_enabled", 0) context.country_list = get_country_list() + context.checkout_prefill = get_checkout_prefill(context.country_list) context.store_pickup_addresses = ( get_store_pickup_addresses() if commera_settings.store_pickup_enabled else [] ) @@ -51,6 +68,22 @@ def get_context(context, allow_guest: bool = False): ] +def get_checkout_prefill(country_list: list[dict]) -> dict: + prefill = {} + for param, field in CHECKOUT_PREFILL_PARAMS.items(): + if value := cstr(frappe.form_dict.get(param)).strip(): + prefill[field] = value + + if country := prefill.pop("country", "").lower(): + country_name = next( + (row["name"] for row in country_list if country in (row["name"].lower(), row["code"].lower())), + None, + ) + if country_name: + prefill["country"] = country_name + return prefill + + def set_guest_cart_context(context): # The guest's cart lives in the browser until the address step books it to the typed email. context.cart_quotation = None diff --git a/commera/www/cart/permalink.html b/commera/www/cart/permalink.html new file mode 100644 index 00000000..8ebd9b8c --- /dev/null +++ b/commera/www/cart/permalink.html @@ -0,0 +1,22 @@ +{% extends "templates/layout.html" %} + +{% block body %} +{% include "templates/includes/cart-permalink.html" %} +
+ {% if lines and not notices %} +

{{ _("Adding the products to your bag...") }}

+ {% else %} +

{{ _("Some products in this link were changed") if lines else _("This cart link has nothing to add") }}

+
    + {% for notice in notices %} +
  • {{ notice | e }}
  • + {% endfor %} +
+ + {{ _("Continue") if lines else _("Go to your bag") }} + + {% endif %} +
+{% endblock %} diff --git a/commera/www/cart/permalink.py b/commera/www/cart/permalink.py new file mode 100644 index 00000000..86a88b7c --- /dev/null +++ b/commera/www/cart/permalink.py @@ -0,0 +1,215 @@ +from urllib.parse import quote, urlencode + +import frappe +from frappe import _ +from frappe.utils.data import cint, cstr + +from commera.api.cart import get_cart_price +from commera.guest import is_guest, is_guest_checkout_enabled +from commera.product_detail import size_sort_key +from commera.utils import get_available_stocks + +no_cache = True + +MAX_CART_LINES = 25 +FORWARDED_PARAM_PREFIXES = ("utm_", "checkout[") + + +def get_context(context): + redirect_to_language_route() + requested_quantities, notices = parse_cart_codes(frappe.form_dict.get("codes")) + context.lines = get_cart_lines(requested_quantities, notices) + context.notices = notices + context.next_url = get_next_url() if context.lines else f"/{frappe.local.lang}/cart" + + +def redirect_to_language_route(): + request = getattr(frappe.local, "request", None) + if not request or request.path.startswith(("/en/", "/ar/")): + return + + # Not a website_redirects rule: that rebuilds the target from the decoded path, so a "?" in an item code + # would start the query string. + target = f"/en/cart/{quote(cstr(frappe.form_dict.get('codes')), safe=':,')}" + if query_string := frappe.safe_decode(request.query_string): + target = f"{target}?{query_string}" + frappe.redirect(target) + + +def parse_cart_codes(codes: str | None) -> tuple[dict[str, int], list[str]]: + requested_quantities = {} + notices = [] + max_pairs = MAX_CART_LINES * 2 + pairs = cstr(codes).split(",", max_pairs) + skipped_count = pairs.pop().count(",") + 1 if len(pairs) > max_pairs else 0 + for pair in pairs: + pair = pair.strip() + if not pair: + continue + + item_code, separator, qty = pair.rpartition(":") + if not separator: + item_code, qty = pair, "1" + item_code = item_code.strip() + if not item_code or not qty.strip().isdigit() or cint(qty) < 1: + notices.append(_("{0} was skipped: the quantity must be a whole number above zero.").format(pair)) + continue + + if item_code not in requested_quantities and len(requested_quantities) >= MAX_CART_LINES: + skipped_count += 1 + continue + requested_quantities[item_code] = requested_quantities.get(item_code, 0) + cint(qty) + + if skipped_count: + notices.append( + _("{0} more products were skipped: a cart link holds at most {1} products.").format( + skipped_count, MAX_CART_LINES + ) + ) + return requested_quantities, notices + + +def get_cart_lines(requested_quantities: dict[str, int], notices: list[str]) -> list[dict]: + if not requested_quantities: + return [] + + size_row_by_item_code = {row.item_code: row for row in get_sellable_size_rows(list(requested_quantities))} + variant_names = list({row.parent for row in size_row_by_item_code.values()}) + sizes_by_variant = get_sizes_by_variant(variant_names) + stock_detail_by_item_code = { + size["item_code"]: size["stock_detail"] for sizes in sizes_by_variant.values() for size in sizes + } + variant_by_name = get_variants(variant_names) + commera_settings = frappe.get_cached_doc("Commera Settings") + price_lists = (commera_settings.get_default_price_list(), commera_settings.get_sale_price_list()) + + lines = [] + for item_code, requested_qty in requested_quantities.items(): + size_row = size_row_by_item_code.get(item_code) + if not size_row: + notices.append(_("{0} is not available.").format(item_code)) + continue + + stock_detail = stock_detail_by_item_code[item_code] + in_stock_qty = cint(stock_detail.get("stock_qty")) + if in_stock_qty < 1: + notices.append(_("{0} is out of stock.").format(size_row.item_name)) + continue + if requested_qty > in_stock_qty: + notices.append( + _("Only {0} of {1} are in stock, so the quantity was lowered.").format( + in_stock_qty, size_row.item_name + ) + ) + + default_price, price = get_cart_price(item_code, *price_lists) + variant = variant_by_name[size_row.parent] + lines.append( + { + "item": variant, + "variant": { + "item_code": item_code, + "size": size_row.size, + "stock_detail": stock_detail, + "item_name": size_row.item_name, + "item_name_ar": size_row.get("item_name_ar") or size_row.item_name, + }, + "qty": min(requested_qty, in_stock_qty), + "price": price, + "default_price": default_price, + "brand": variant.brand or "", + "sizes": sizes_by_variant[size_row.parent], + } + ) + + return lines + + +def get_sellable_size_rows(item_codes: list[str]) -> list[dict]: + color_size_item = frappe.qb.DocType("Color Size Item") + style_attribute_variant = frappe.qb.DocType("Style Attribute Variant") + item = frappe.qb.DocType("Item") + query = ( + frappe.qb.from_(color_size_item) + .join(style_attribute_variant) + .on(style_attribute_variant.name == color_size_item.parent) + .join(item) + .on(item.name == color_size_item.item_code) + .select(color_size_item.item_code, color_size_item.size, color_size_item.parent, item.item_name) + .where(color_size_item.parenttype == "Style Attribute Variant") + .where(color_size_item.item_code.isin(item_codes)) + .where(style_attribute_variant.is_published == 1) + .where(item.disabled == 0) + ) + if frappe.db.has_column("Item", "custom_item_name_ar"): + query = query.select(item.custom_item_name_ar.as_("item_name_ar")) + return query.run(as_dict=True) + + +def get_sizes_by_variant(variant_names: list[str]) -> dict[str, list[dict]]: + if not variant_names: + return {} + + size_rows = frappe.get_all( + "Color Size Item", + filters={"parenttype": "Style Attribute Variant", "parent": ["in", variant_names]}, + fields=["parent", "item_code", "size"], + ) + warehouse = frappe.get_cached_value("Commera Settings", "Commera Settings", "ecommerce_warehouse") + stock_by_item_code = get_available_stocks([row.item_code for row in size_rows], warehouse) + + sizes_by_variant = {variant_name: [] for variant_name in variant_names} + for row in size_rows: + sizes_by_variant[row.parent].append( + {"item_code": row.item_code, "size": row.size, "stock_detail": stock_by_item_code[row.item_code]} + ) + for sizes in sizes_by_variant.values(): + sizes.sort(key=lambda size: size_sort_key(size["size"])) + return sizes_by_variant + + +def get_variants(variant_names: list[str]) -> dict[str, dict]: + if not variant_names: + return {} + + style_attribute_variant = frappe.qb.DocType("Style Attribute Variant") + item = frappe.qb.DocType("Item") + variants = ( + frappe.qb.from_(style_attribute_variant) + .left_join(item) + .on(item.name == style_attribute_variant.item_style) + .select( + style_attribute_variant.name, + style_attribute_variant.display_name, + style_attribute_variant.route, + style_attribute_variant.item_style, + item.brand, + ) + .where(style_attribute_variant.name.isin(variant_names)) + ).run(as_dict=True) + images = frappe.get_all( + "Website Slideshow Item", + filters={"parenttype": "Style Attribute Variant", "parent": ["in", variant_names]}, + fields=["parent", "image"], + order_by="idx asc", + ) + + variant_by_name = {variant.name: frappe._dict(variant, images=[]) for variant in variants} + for image in images: + variant_by_name[image.parent].images.append({"image": image.image}) + return variant_by_name + + +def get_next_url() -> str: + forwarded_params = [ + (key, value) + for key, value in frappe.form_dict.items() + if key == "discount" or key.startswith(FORWARDED_PARAM_PREFIXES) + ] + cart_url = f"/{frappe.local.lang}/cart" + if not is_guest(): + forwarded_params = [("checkout", "1"), *forwarded_params] + elif is_guest_checkout_enabled(): + cart_url = f"{cart_url}/checkout" + query_string = urlencode(forwarded_params, doseq=True) + return f"{cart_url}?{query_string}" if query_string else cart_url diff --git a/docs/implementation/media/cart-copy-link.png b/docs/implementation/media/cart-copy-link.png new file mode 100644 index 00000000..aa81ffe8 Binary files /dev/null and b/docs/implementation/media/cart-copy-link.png differ diff --git a/docs/implementation/media/cart-link-checkout.png b/docs/implementation/media/cart-link-checkout.png new file mode 100644 index 00000000..685a9b31 Binary files /dev/null and b/docs/implementation/media/cart-link-checkout.png differ diff --git a/docs/implementation/media/cart-link-discount.png b/docs/implementation/media/cart-link-discount.png new file mode 100644 index 00000000..1be29a4a Binary files /dev/null and b/docs/implementation/media/cart-link-discount.png differ diff --git a/docs/implementation/media/checkout-copy-link.png b/docs/implementation/media/checkout-copy-link.png new file mode 100644 index 00000000..262e573e Binary files /dev/null and b/docs/implementation/media/checkout-copy-link.png differ