From 26c7e2cfabc7c60187e9d5b043ea8c7af65de5a1 Mon Sep 17 00:00:00 2001 From: Okiljon Date: Wed, 24 Sep 2025 18:33:54 +0500 Subject: [PATCH 1/3] Create Ivy Example for NuGet ShopifySharp --- shopifysharp/.gitignore | 7 +++ shopifysharp/Apps/ProductsApp.cs | 86 ++++++++++++++++++++++++++++ shopifysharp/GlobalUsings.cs | 30 ++++++++++ shopifysharp/Program.cs | 12 ++++ shopifysharp/README.md | 26 +++++++++ shopifysharp/ShopifySharpDemo.csproj | 24 ++++++++ 6 files changed, 185 insertions(+) create mode 100644 shopifysharp/.gitignore create mode 100644 shopifysharp/Apps/ProductsApp.cs create mode 100644 shopifysharp/GlobalUsings.cs create mode 100644 shopifysharp/Program.cs create mode 100644 shopifysharp/README.md create mode 100644 shopifysharp/ShopifySharpDemo.csproj diff --git a/shopifysharp/.gitignore b/shopifysharp/.gitignore new file mode 100644 index 00000000..fe1554b2 --- /dev/null +++ b/shopifysharp/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +*.sln.iml +.idea/**/* +**/.idea/* +.env +.env.* \ No newline at end of file diff --git a/shopifysharp/Apps/ProductsApp.cs b/shopifysharp/Apps/ProductsApp.cs new file mode 100644 index 00000000..9ed99f7f --- /dev/null +++ b/shopifysharp/Apps/ProductsApp.cs @@ -0,0 +1,86 @@ +namespace ShopifySharpDemo.Apps; + +[App(icon: Icons.ShoppingBag, title: "Shopify Products", path: ["Apps"])] +public class ProductsApp : ViewBase +{ + public override object? Build() + { + var ShopDomain = Environment.GetEnvironmentVariable("SHOPIFY_SHOP_DOMAIN") ?? ""; + var AccessToken = Environment.GetEnvironmentVariable("SHOPIFY_ACCESS_TOKEN") ?? ""; + var productService = new ProductService(ShopDomain, AccessToken); + var products = this.UseState(() => null); + var isLoading = this.UseState(true); + var error = this.UseState(() => null); + + this.UseEffect(async () => + { + try + { + isLoading.Value = true; + var result = await productService.ListAsync(); + products.Value = result?.Items?.ToArray() ?? Array.Empty(); + } + catch (ShopifyException ex) + { + error.Value = $"Shopify error: {ex.Message}"; + } + catch (Exception ex) + { + error.Value = ex.Message; + } + finally + { + isLoading.Value = false; + } + }, []); + + object productCard(Product p) + { + var imageUrl = p.Images?.FirstOrDefault()?.Src ?? ""; + var price = p.Variants?.FirstOrDefault()?.Price ?? 0m; + + if (string.IsNullOrWhiteSpace(imageUrl)) + { + return new Card( + Layout.Vertical().Gap(3) + | Text.H4(p.Title ?? "Untitled") + | Text.Block(price > 0 ? $"{price:C}" : "") + ); + } + + return new Card( + Layout.Vertical().Gap(3) + | Text.H4(p.Title ?? "Untitled") + | new Html($"") + | Text.Block(price > 0 ? $"{price:C}" : "") + ); + } + + var header = Layout.Horizontal().Align(Align.Right) + | Text.Block("Domain: " + "example.myshopify.com"); + + object body; + if (error.Value != null) + { + body = Text.Block("Error: " + error.Value); + } + else if (isLoading.Value) + { + body = Layout.Center() | Text.Block("Loading products..."); + } + else if (products.Value?.Length == 0) + { + body = Layout.Center() | Text.Block("No products found."); + } + else + { + body = Layout.Grid().Columns(4) | products.Value!.Select(productCard).ToArray(); + } + + return Layout.Horizontal().Align(Align.Center) + | new HeaderLayout(header, Layout.Vertical().Gap(4) + | Text.H2("Products") + | body + ).Width(Size.Full().Max(300)); + } +} \ No newline at end of file diff --git a/shopifysharp/GlobalUsings.cs b/shopifysharp/GlobalUsings.cs new file mode 100644 index 00000000..daefabbe --- /dev/null +++ b/shopifysharp/GlobalUsings.cs @@ -0,0 +1,30 @@ +global using Ivy; +global using Ivy.Apps; +global using Ivy.Auth; +global using Ivy.Chrome; +global using Ivy.Client; +global using Ivy.Core; +global using Ivy.Core.Hooks; +global using Ivy.Helpers; +global using Ivy.Hooks; +global using Ivy.Shared; +global using Ivy.Services; +global using Ivy.Views; +global using Ivy.Views.Alerts; +global using Ivy.Views.Blades; +global using Ivy.Views.Builders; +global using Ivy.Views.Charts; +global using Ivy.Views.Dashboards; +global using Ivy.Views.Forms; +global using Ivy.Views.Tables; +global using Ivy.Widgets.Inputs; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using System.Collections.Immutable; +global using System.ComponentModel.DataAnnotations; +global using System.Globalization; +global using System.Reactive.Linq; +global using ShopifySharp; + +namespace ShopifySharpDemo; diff --git a/shopifysharp/Program.cs b/shopifysharp/Program.cs new file mode 100644 index 00000000..1f1649dd --- /dev/null +++ b/shopifysharp/Program.cs @@ -0,0 +1,12 @@ +using ShopifySharpDemo.Apps; + +CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("en-US"); +var server = new Server(); +#if DEBUG +server.UseHotReload(); +#endif +server.AddAppsFromAssembly(); +server.AddConnectionsFromAssembly(); +var chromeSettings = new ChromeSettings().DefaultApp().UseTabs(preventDuplicates: true); +server.UseChrome(chromeSettings); +await server.RunAsync(); \ No newline at end of file diff --git a/shopifysharp/README.md b/shopifysharp/README.md new file mode 100644 index 00000000..e9332a5f --- /dev/null +++ b/shopifysharp/README.md @@ -0,0 +1,26 @@ +# ShopifySharp Demo + +Web application created using [Ivy](https://github.com/Ivy-Interactive/Ivy). + +Ivy is a web framework for building interactive web applications using C# and .NET. + +## Setup + +Before running, set the required Shopify environment variables: + +```powershell +$env:SHOPIFY_SHOP_DOMAIN="your-store.myshopify.com" +$env:SHOPIFY_ACCESS_TOKEN="your-access-token" +``` + +## Run + +``` +dotnet watch +``` + +## Deploy + +``` +ivy deploy +``` \ No newline at end of file diff --git a/shopifysharp/ShopifySharpDemo.csproj b/shopifysharp/ShopifySharpDemo.csproj new file mode 100644 index 00000000..4cf472fb --- /dev/null +++ b/shopifysharp/ShopifySharpDemo.csproj @@ -0,0 +1,24 @@ + + + Exe + net9.0 + enable + enable + CS8618;CS8603;CS8602;CS8604;CS9113 + ShopifySharpDemo + + + + + + + + + + + + + + + + From 6b7b19b73fc7d90a0c55d3dbfd47f45e7612cef6 Mon Sep 17 00:00:00 2001 From: Okiljon Date: Thu, 25 Sep 2025 21:27:35 +0500 Subject: [PATCH 2/3] Add input fields for Domain & Access token --- shopifysharp/Apps/ProductsApp.cs | 42 ++++++++++++++++++++------------ shopifysharp/README.md | 9 ------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/shopifysharp/Apps/ProductsApp.cs b/shopifysharp/Apps/ProductsApp.cs index 9ed99f7f..ead64f02 100644 --- a/shopifysharp/Apps/ProductsApp.cs +++ b/shopifysharp/Apps/ProductsApp.cs @@ -5,18 +5,25 @@ public class ProductsApp : ViewBase { public override object? Build() { - var ShopDomain = Environment.GetEnvironmentVariable("SHOPIFY_SHOP_DOMAIN") ?? ""; - var AccessToken = Environment.GetEnvironmentVariable("SHOPIFY_ACCESS_TOKEN") ?? ""; - var productService = new ProductService(ShopDomain, AccessToken); + var shopDomain = this.UseState(() => ""); + var accessToken = this.UseState(() => ""); var products = this.UseState(() => null); - var isLoading = this.UseState(true); + var isLoading = this.UseState(false); var error = this.UseState(() => null); - this.UseEffect(async () => + async Task LoadProducts() { + if (string.IsNullOrWhiteSpace(shopDomain.Value) || string.IsNullOrWhiteSpace(accessToken.Value)) + { + error.Value = "Please fill in both domain and access token fields"; + return; + } + try { isLoading.Value = true; + error.Value = null; + var productService = new ProductService(shopDomain.Value, accessToken.Value); var result = await productService.ListAsync(); products.Value = result?.Items?.ToArray() ?? Array.Empty(); } @@ -32,7 +39,7 @@ public class ProductsApp : ViewBase { isLoading.Value = false; } - }, []); + } object productCard(Product p) { @@ -56,8 +63,11 @@ object productCard(Product p) ); } - var header = Layout.Horizontal().Align(Align.Right) - | Text.Block("Domain: " + "example.myshopify.com"); + var header = Layout.Vertical().Gap(3) + | Text.H3("Shopify Products") + | shopDomain.ToTextInput().Placeholder("Domain (e.g.: example.myshopify.com)") + | accessToken.ToTextInput().Placeholder("Access Token") + | new Button("Get Products", onClick: async _ => await LoadProducts()); object body; if (error.Value != null) @@ -68,19 +78,21 @@ object productCard(Product p) { body = Layout.Center() | Text.Block("Loading products..."); } - else if (products.Value?.Length == 0) + else if (products.Value == null) + { + body = Layout.Center() | Text.Block("Click the button above to view products"); + } + else if (products.Value.Length == 0) { body = Layout.Center() | Text.Block("No products found."); } else { - body = Layout.Grid().Columns(4) | products.Value!.Select(productCard).ToArray(); + body = Layout.Grid().Columns(4) | products.Value.Select(productCard).ToArray(); } - return Layout.Horizontal().Align(Align.Center) - | new HeaderLayout(header, Layout.Vertical().Gap(4) - | Text.H2("Products") - | body - ).Width(Size.Full().Max(300)); + return Layout.Vertical().Gap(4) + | header + | body; } } \ No newline at end of file diff --git a/shopifysharp/README.md b/shopifysharp/README.md index e9332a5f..e13f2cc6 100644 --- a/shopifysharp/README.md +++ b/shopifysharp/README.md @@ -4,15 +4,6 @@ Web application created using [Ivy](https://github.com/Ivy-Interactive/Ivy). Ivy is a web framework for building interactive web applications using C# and .NET. -## Setup - -Before running, set the required Shopify environment variables: - -```powershell -$env:SHOPIFY_SHOP_DOMAIN="your-store.myshopify.com" -$env:SHOPIFY_ACCESS_TOKEN="your-access-token" -``` - ## Run ``` From ddfb8c1e6fd0aaa856b2b36f759d4a72f17bf373 Mon Sep 17 00:00:00 2001 From: Okiljon Date: Sat, 27 Sep 2025 14:54:33 +0500 Subject: [PATCH 3/3] Fix: Use Shopify GraphService --- shopifysharp/Apps/ProductsApp.cs | 155 ++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 3 deletions(-) diff --git a/shopifysharp/Apps/ProductsApp.cs b/shopifysharp/Apps/ProductsApp.cs index ead64f02..47d632ce 100644 --- a/shopifysharp/Apps/ProductsApp.cs +++ b/shopifysharp/Apps/ProductsApp.cs @@ -23,9 +23,109 @@ async Task LoadProducts() { isLoading.Value = true; error.Value = null; - var productService = new ProductService(shopDomain.Value, accessToken.Value); - var result = await productService.ListAsync(); - products.Value = result?.Items?.ToArray() ?? Array.Empty(); + var graph = new GraphService(shopDomain.Value, accessToken.Value); + var query = @"query { + products(first: 20, sortKey: TITLE) { + nodes { + title + images(first: 1) { edges { node { url src } } } + variants(first: 1) { edges { node { price } } } + } + } + }"; + + var request = new GraphRequest { Query = query }; + var response = await graph.PostAsync(request); + + System.Text.Json.JsonElement itemsElement = default; + bool itemsAreEdges = false; + if (response?.Data is not null) + { + var root = response.Data.RootElement; + if (root.ValueKind == System.Text.Json.JsonValueKind.Object) + { + System.Text.Json.JsonElement productsEl; + if (root.TryGetProperty("products", out productsEl)) + { + // Data already points at the GraphQL 'data' object + } + else if (root.TryGetProperty("data", out var dataEl) && dataEl.TryGetProperty("products", out productsEl)) + { + // Some serializers may keep the envelope; handle just in case + } + else + { + productsEl = default; + } + + if (productsEl.ValueKind != System.Text.Json.JsonValueKind.Undefined && productsEl.ValueKind != System.Text.Json.JsonValueKind.Null) + { + if (productsEl.TryGetProperty("nodes", out var nodesEl)) + { + itemsElement = nodesEl; + itemsAreEdges = false; + } + else if (productsEl.TryGetProperty("edges", out var edgesEl)) + { + itemsElement = edgesEl; + itemsAreEdges = true; + } + } + } + } + + var mapped = itemsElement.ValueKind == System.Text.Json.JsonValueKind.Array + ? itemsElement.EnumerateArray().Select(itemEl => + { + var nodeEl = itemsAreEdges && itemEl.TryGetProperty("node", out var n) ? n : (itemsAreEdges ? default : itemEl); + var title = nodeEl.ValueKind == System.Text.Json.JsonValueKind.Object && nodeEl.TryGetProperty("title", out var t) ? t.GetString() : null; + + string imageUrl = string.Empty; + if (nodeEl.TryGetProperty("images", out var imagesEl) + && imagesEl.TryGetProperty("edges", out var iEdgesEl)) + { + var ie = iEdgesEl.EnumerateArray(); + if (ie.MoveNext()) + { + var imageNode = ie.Current.TryGetProperty("node", out var inEl) ? inEl : default; + if (imageNode.ValueKind == System.Text.Json.JsonValueKind.Object) + { + if (imageNode.TryGetProperty("url", out var urlEl)) imageUrl = urlEl.GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(imageUrl) && imageNode.TryGetProperty("src", out var srcEl)) imageUrl = srcEl.GetString() ?? string.Empty; + } + } + } + + decimal price = 0m; + if (nodeEl.TryGetProperty("variants", out var variantsEl) + && variantsEl.TryGetProperty("edges", out var vEdgesEl)) + { + var ve = vEdgesEl.EnumerateArray(); + if (ve.MoveNext()) + { + var vNode = ve.Current.TryGetProperty("node", out var vn) ? vn : default; + if (vNode.ValueKind == System.Text.Json.JsonValueKind.Object + && vNode.TryGetProperty("price", out var priceEl)) + { + var priceStr = priceEl.GetString(); + if (!string.IsNullOrWhiteSpace(priceStr)) + { + decimal.TryParse(priceStr, NumberStyles.Any, CultureInfo.InvariantCulture, out price); + } + } + } + } + + return new Product + { + Title = title, + Images = string.IsNullOrWhiteSpace(imageUrl) ? null : new List { new ProductImage { Src = imageUrl } }, + Variants = new List { new ProductVariant { Price = price } } + }; + }).ToArray() + : Array.Empty(); + + products.Value = mapped; } catch (ShopifyException ex) { @@ -95,4 +195,53 @@ object productCard(Product p) | header | body; } + // GraphQL helper DTOs + private sealed class GraphQlResponse + { + public T? Data { get; set; } + public IEnumerable? Errors { get; set; } + } + + private sealed class GraphQlError + { + public string? Message { get; set; } + } + + private sealed class ProductsData + { + public Connection? Products { get; set; } + } + + private sealed class Connection + { + public List>? Edges { get; set; } + } + + private sealed class Edge + { + public TNode? Node { get; set; } + } + + private sealed class ProductNode + { + public string? Title { get; set; } + public Connection? Images { get; set; } + public Connection? Variants { get; set; } + } + + private sealed class ImageNode + { + public string? Url { get; set; } + public string? Src { get; set; } + } + + private sealed class VariantNode + { + public MoneyV2? Price { get; set; } + } + + private sealed class MoneyV2 + { + public string? Amount { get; set; } + } } \ No newline at end of file