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..47d632ce --- /dev/null +++ b/shopifysharp/Apps/ProductsApp.cs @@ -0,0 +1,247 @@ +namespace ShopifySharpDemo.Apps; + +[App(icon: Icons.ShoppingBag, title: "Shopify Products", path: ["Apps"])] +public class ProductsApp : ViewBase +{ + public override object? Build() + { + var shopDomain = this.UseState(() => ""); + var accessToken = this.UseState(() => ""); + var products = this.UseState(() => null); + var isLoading = this.UseState(false); + var error = this.UseState(() => null); + + 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 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) + { + 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.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) + { + body = Text.Block("Error: " + error.Value); + } + else if (isLoading.Value) + { + body = Layout.Center() | Text.Block("Loading products..."); + } + 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(); + } + + return Layout.Vertical().Gap(4) + | 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 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..e13f2cc6 --- /dev/null +++ b/shopifysharp/README.md @@ -0,0 +1,17 @@ +# 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. + +## 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 + + + + + + + + + + + + + + + +