diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..06558cd --- /dev/null +++ b/.drone.yml @@ -0,0 +1,117 @@ +--- +kind: pipeline +type: docker +name: build + +steps: + - name: dotnet-build + image: mcr.microsoft.com/dotnet/sdk:10.0 + commands: + - dotnet restore LiteCharmsShopAdmin.slnx + - dotnet build LiteCharmsShopAdmin.slnx -c Release + + - name: dotnet-test + image: mcr.microsoft.com/dotnet/sdk:10.0 + commands: + - dotnet restore LiteCharmsShopAdmin.slnx + - dotnet test LiteCharmsShopAdmin.slnx -c Release --no-restore + +trigger: + event: [ pull_request ] + +--- +kind: pipeline +type: docker +name: package + +steps: + - name: docker-build + image: plugins/docker + settings: + registry: nexus.khongisa.co.za + repo: nexus.khongisa.co.za/litecharms-shopadmin-uat + tags: [ latest, "1.${DRONE_BUILD_NUMBER}" ] + custom_labels: + - org.opencontainers.image.source=https://gitea.khongisa.co.za/litecharms/litecharms-shopadmin-uat + - org.opencontainers.image.version=1.${DRONE_BUILD_NUMBER} + - org.opencontainers.image.revision=${DRONE_COMMIT_SHA} + username: { from_secret: docker_username } + password: { from_secret: docker_password } + + - name: gitea-tag-release + image: alpine/git + environment: + GITEA_TOKEN: { from_secret: git_token } + GITEA_USER: { from_secret: git_username } + GITEA_PASS: { from_secret: git_password } + commands: + - echo "169.255.58.144 gitea.khongisa.co.za" >> /etc/hosts + - apk add --no-cache curl + - git remote set-url origin https://$${GITEA_USER}:$${GITEA_PASS}@gitea.khongisa.co.za/litecharms/shopadmin.git + - git tag 1.${DRONE_BUILD_NUMBER} + - git push origin 1.${DRONE_BUILD_NUMBER} + - | + curl -X POST "https://gitea.khongisa.co.za/api/v1/repos/litecharms/shopadmin/releases" \ + -H "Authorization: token $${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"tag_name\": \"1.${DRONE_BUILD_NUMBER}\", + \"target_commitish\": \"${DRONE_COMMIT_SHA}\", + \"name\": \"Release 1.${DRONE_BUILD_NUMBER}\", + \"body\": \"### Artifacts\n* **Docker Image:** nexus.khongisa.co.za/shopadmin:1.${DRONE_BUILD_NUMBER}\n* **NuGet:** [View on Nexus](https://nexus.khongisa.co.za/repository/nuget-group/)\", + \"draft\": false, + \"prerelease\": false + }" + +depends_on: + - build + +trigger: + event: [ pull_request ] + +--- +kind: pipeline +type: docker +name: uat + +steps: + - name: deploy + image: bitnami/kubectl:latest + environment: + KUBE_CONFIG: { from_secret: kube_config } + commands: + - mkdir -p $HOME/.kube + - echo "$KUBE_CONFIG" > $HOME/.kube/config + - kubectl apply -f litecharms-shopadmin-uat.yml + - sleep 10 + - kubectl rollout restart deployment/litecharms-shopadmin -n litecharms-shopadmin-uat + +depends_on: + - package + +trigger: + event: [ pull_request ] + +--- +kind: pipeline +type: docker +name: prod + +steps: + - name: deploy + image: bitnami/kubectl:latest + environment: + KUBE_CONFIG: { from_secret: kube_config } + commands: + - mkdir -p $HOME/.kube + - echo "$KUBE_CONFIG" > $HOME/.kube/config + - kubectl apply -f litecharms-shopadmin.yml + - sleep 10 + - kubectl rollout restart deployment/litecharms-shopadmin -n litecharms-shopadmin + +depends_on: + - uat + +trigger: + event: [ promote ] + target: [ production ] \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a5bdbf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,248 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Code Actions #### + +# Type members +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_property_generation_behavior = prefer_throwing_properties + +# Symbol search +dotnet_search_reference_assemblies = true + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_prefer_system_hash_code = true +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_collection_expression = when_types_loosely_match +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_non_hidden_explicit_cast_in_source = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = true +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_anonymous_function = true +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true + +# Code-block preferences +csharp_prefer_braces = true +csharp_prefer_simple_using_statement = true +csharp_prefer_system_threading_lock = true +csharp_style_namespace_declarations = block_scoped +csharp_style_prefer_method_group_conversion = true +csharp_style_prefer_primary_constructors = true +csharp_style_prefer_simple_property_accessors = true +csharp_style_prefer_top_level_statements = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_implicitly_typed_lambda_expression = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_unbound_generic_type_in_nameof = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/.filenesting.json b/.filenesting.json new file mode 100644 index 0000000..483aefc --- /dev/null +++ b/.filenesting.json @@ -0,0 +1,14 @@ +{ + "root": true, + "dependentFileProviders": { + "add": { + "addedExtension": {}, + "extensionToExtension": { + "add": { + ".css": [ ".razor" ], + ".cs": [ ".razor" ] + } + } + } + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..adb766b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Stage 1: Build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /app + +COPY ["nuget.config", "./"] +COPY ["ShopAdmin/ShopAdmin.csproj", "Shop/"] +RUN dotnet restore "ShopAdmin/ShopAdmin.csproj" --configfile nuget.config +COPY . . +RUN dotnet publish "ShopAdmin/ShopAdmin.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Stage 2: Final Image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +EXPOSE 8080 +EXPOSE 8081 + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "ShopAdmin.dll"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d3f8705 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +PROPRIETARY LICENSE + +Copyright (c) 2026 Lite Charms (PTY) Ltd. All rights reserved. + +This software and its associated documentation (the "Software") are the +proprietary property of Lite Charms (PTY) Ltd. + +The Software is provided for internal use only. Unauthorized copying, +distribution, modification, or use of this file via any medium is +strictly prohibited. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/LiteCharms.snk b/LiteCharms.snk new file mode 100644 index 0000000..598e6da Binary files /dev/null and b/LiteCharms.snk differ diff --git a/LiteCharmsShopAdmin.slnx b/LiteCharmsShopAdmin.slnx index 4e2253d..3b3c6ff 100644 --- a/LiteCharmsShopAdmin.slnx +++ b/LiteCharmsShopAdmin.slnx @@ -1 +1,3 @@ - + + + diff --git a/README.md b/README.md index 93354d9..6e62d9d 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# LiteCharmsShopAdmin \ No newline at end of file +# LiteCharms Shop Admin + +The primary internal back-office and operations management interface for the LiteCharms platform. + +## ⚙️ Core Functions +* **Product Engine:** Configure software packages (REST, gRPC, Identity Server), define add-ons, and maintain localized price lists. +* **Process Override:** Manually progress order states, trigger overrides, and control customer workflow states. +* **Analytics & Reports:** Monitor sales metrics, conversion funnels, and track intent data from the shop analytics schema. + +## 🏗 Architecture +* **Type:** Kubernetes Deployment (`litecharms-admin` namespace) +* **Scale:** Standalone high-availability instance. +* **Ingress:** Restricted access via corporate VPN / secure tunnel routing via Traefik/IngressRoute. + +## 🚀 CI/CD Workflow +* **Trigger:** Pull Request to `master`. +* **Build:** Compiles Blazor WebAssembly / Server .NET 10.0 source. +* **Containerize:** Docker image built and pushed to private Nexus registry as `litecharms-admin:1.${DRONE_BUILD_NUMBER}`. +* **Deploy:** * Updates internal validation environment via GitOps/`kubectl apply`. + * Production release handled via Drone **Promotion** target. + +## 🌐 Endpoints +* **Internal Hub:** `https://shop-admin.khongisa.co.za` (Restricted Network Access) +* **State Sync:** Directly interfaces with the core PostgreSQL instance and publishes MediatR commands to the isolated `litecharms-scheduler` process. \ No newline at end of file diff --git a/ShopAdmin/Components/App.razor b/ShopAdmin/Components/App.razor new file mode 100644 index 0000000..bd68990 --- /dev/null +++ b/ShopAdmin/Components/App.razor @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ShopAdmin/Components/Layout/MainLayout.razor b/ShopAdmin/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..2ebe789 --- /dev/null +++ b/ShopAdmin/Components/Layout/MainLayout.razor @@ -0,0 +1,19 @@ +@inherits LayoutComponentBase + +
+
+
+ About +
+ +
+ @* @Body *@ +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/ShopAdmin/Components/Layout/MainLayout.razor.css b/ShopAdmin/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..38d1f25 --- /dev/null +++ b/ShopAdmin/Components/Layout/MainLayout.razor.css @@ -0,0 +1,98 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/ShopAdmin/Components/Layout/ReconnectModal.razor b/ShopAdmin/Components/Layout/ReconnectModal.razor new file mode 100644 index 0000000..e740b0c --- /dev/null +++ b/ShopAdmin/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/ShopAdmin/Components/Layout/ReconnectModal.razor.css b/ShopAdmin/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..3ad3773 --- /dev/null +++ b/ShopAdmin/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/ShopAdmin/Components/Layout/ReconnectModal.razor.js b/ShopAdmin/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..a44de78 --- /dev/null +++ b/ShopAdmin/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/ShopAdmin/Components/Pages/Counter.razor b/ShopAdmin/Components/Pages/Counter.razor new file mode 100644 index 0000000..1a4f8e7 --- /dev/null +++ b/ShopAdmin/Components/Pages/Counter.razor @@ -0,0 +1,19 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/ShopAdmin/Components/Pages/Error.razor b/ShopAdmin/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/ShopAdmin/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/ShopAdmin/Components/Pages/Home.razor b/ShopAdmin/Components/Pages/Home.razor new file mode 100644 index 0000000..9001e0b --- /dev/null +++ b/ShopAdmin/Components/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. diff --git a/ShopAdmin/Components/Pages/NotFound.razor b/ShopAdmin/Components/Pages/NotFound.razor new file mode 100644 index 0000000..917ada1 --- /dev/null +++ b/ShopAdmin/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/ShopAdmin/Components/Pages/Weather.razor b/ShopAdmin/Components/Pages/Weather.razor new file mode 100644 index 0000000..f437e5e --- /dev/null +++ b/ShopAdmin/Components/Pages/Weather.razor @@ -0,0 +1,64 @@ +@page "/weather" +@attribute [StreamRendering] + +Weather + +

Weather

+ +

This component demonstrates showing data.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + // Simulate asynchronous loading to demonstrate streaming rendering + await Task.Delay(500); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + } + + private class WeatherForecast + { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/ShopAdmin/Components/Routes.razor b/ShopAdmin/Components/Routes.razor new file mode 100644 index 0000000..105855d --- /dev/null +++ b/ShopAdmin/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/ShopAdmin/Components/_Imports.razor b/ShopAdmin/Components/_Imports.razor new file mode 100644 index 0000000..5fd3442 --- /dev/null +++ b/ShopAdmin/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using ShopAdmin +@using ShopAdmin.Components +@using ShopAdmin.Components.Layout diff --git a/ShopAdmin/Program.cs b/ShopAdmin/Program.cs new file mode 100644 index 0000000..821838b --- /dev/null +++ b/ShopAdmin/Program.cs @@ -0,0 +1,64 @@ +using LiteCharms.Features.Extensions; +using LiteCharms.Features.Mediator; +using ShopAdmin.Components; +using static LiteCharms.Features.Email.Extensions.Constants; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.AddMonitoring(); + +builder.Services.AddControllers(); +builder.Services.AddBlazoredToast(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddMediator(); + +builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>)); +builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>)); + +builder.Services.AddSalesServiceBus(); +builder.Services.AddGeneralServiceBus(); +builder.Services.AddQuartzSchedulerClient(ShopSchedulerName, builder.Configuration); + +builder.Services.AddEmailServices(builder.Configuration); +builder.Services.AddEmailServiceBus(); + +builder.Services.AddShopServices(); +builder.Services.AddShopDatabase(builder.Configuration); + +builder.Services.AddPostgresHealtchCheck(); +builder.Services.AddQuartzHealtchCheck(); +builder.Services.AddHealthChecksSupport(builder.Configuration); + +var app = builder.Build(); + +var schedulerFactory = app.Services.GetRequiredService(); +var scheduler = await schedulerFactory.GetScheduler(ShopSchedulerName); + +if (!scheduler!.IsStarted) + await scheduler.Start(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = HealthChecks.UI.Client.UIResponseWriter.WriteHealthCheckUIResponse +}); + +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); \ No newline at end of file diff --git a/ShopAdmin/Properties/launchSettings.json b/ShopAdmin/Properties/launchSettings.json new file mode 100644 index 0000000..1826785 --- /dev/null +++ b/ShopAdmin/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5052", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7247;http://localhost:5052", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/ShopAdmin/ShopAdmin.csproj b/ShopAdmin/ShopAdmin.csproj new file mode 100644 index 0000000..0eb2409 --- /dev/null +++ b/ShopAdmin/ShopAdmin.csproj @@ -0,0 +1,57 @@ + + + + net10.0 + enable + enable + ..\LiteCharms.snk + true + b929455a-ec27-42f9-bd54-3d54059c01bb + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + diff --git a/ShopAdmin/appsettings.json b/ShopAdmin/appsettings.json new file mode 100644 index 0000000..417425d --- /dev/null +++ b/ShopAdmin/appsettings.json @@ -0,0 +1,23 @@ +{ + "Email": { + "Credentials": { + "Username": "shop@litecharms.co.za" + }, + "Port": 465, + "Host": "mail.litecharms.co.za", + "UseSsl": true + }, + "Monitoring": { + "ApiKey": "", + "Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889", + "ServiceName": "LiteCharms.ShopAdmin" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Error" + } + }, + "AllowedHosts": "*" +} diff --git a/ShopAdmin/wwwroot/app.css b/ShopAdmin/wwwroot/app.css new file mode 100644 index 0000000..73a69d6 --- /dev/null +++ b/ShopAdmin/wwwroot/app.css @@ -0,0 +1,60 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/ShopAdmin/wwwroot/favicon.png b/ShopAdmin/wwwroot/favicon.png new file mode 100644 index 0000000..8422b59 Binary files /dev/null and b/ShopAdmin/wwwroot/favicon.png differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..9c3f829 Binary files /dev/null and b/icon.png differ diff --git a/litecharms-shopadmin-uat.yml b/litecharms-shopadmin-uat.yml new file mode 100644 index 0000000..bfb7a88 --- /dev/null +++ b/litecharms-shopadmin-uat.yml @@ -0,0 +1,160 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: litecharms-shopadmin-uat +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: shop-config + namespace: litecharms-shopadmin-uat +data: + ASPNETCORE_ENVIRONMENT: "Development" + ASPNETCORE_URLS: "http://0.0.0.0:8080" + Monitoring__Address: "http://aspire-dashboard-service.aspire.svc.cluster.local:18889" + Monitoring__ServiceName: "LiteCharms.Shop.Uat" +--- +apiVersion: v1 +kind: Secret +metadata: + name: shop-secrets + namespace: litecharms-shopadmin-uat +type: Opaque +data: + connection-string: SG9zdD0xOTIuMTY4LjEuMTcwO0RhdGFiYXNlPXNob3AtZGV2O1VzZXJuYW1lPXNob3AtZGV2LXVzZXI7UGFzc3dvcmQ9a1ZWbW9XS0ozeHpnUVg7UGVyc2lzdCBTZWN1cml0eSBJbmZvPVRydWU= + connection-string-quartz: SG9zdD0xOTIuMTY4LjEuMTcwO0RhdGFiYXNlPXNjaGVkdWxlci1kZXY7VXNlcm5hbWU9c2NoZWR1bGVyLWRldi11c2VyO1Bhc3N3b3JkPWtWVm1vV0tKM3h6Z1FYO1BlcnNpc3QgU2VjdXJpdHkgSW5mbz1UcnVl + aspire-apikey: bWMzRzYzSzJqNVpPRXNpMEFqTW9qTFRYbTFLRVpGY3R6SUlqU3dEaVRHdXQ4cUdTa1B1V3d4R1AxUmJzY0pVbw== +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shop-data-pvc + namespace: litecharms-shopadmin-uat +spec: + accessModes: ["ReadWriteMany"] + storageClassName: nfs-storage + resources: + requests: + storage: 2Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: litecharms-shopadmin + namespace: litecharms-shopadmin-uat +spec: + replicas: 1 + selector: + matchLabels: + app: shop + template: + metadata: + labels: + app: shop + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: node-role.kubernetes.io/control-plane + operator: DoesNotExist + containers: + - name: shop + image: nexus.khongisa.co.za/litecharms-shopadmin:latest + imagePullPolicy: Always + resources: + limits: + memory: "512Mi" + cpu: "500m" + requests: + memory: "256Mi" + cpu: "100m" + ports: + - containerPort: 8080 + envFrom: + - configMapRef: + name: shop-config + env: + - name: ConnectionStrings__PostgresScheduler + valueFrom: + secretKeyRef: + name: shop-secrets + key: connection-string-quartz + - name: ConnectionStrings__PostgresShop + valueFrom: + secretKeyRef: + name: shop-secrets + key: connection-string + - name: Monitoring__Address + valueFrom: + configMapKeyRef: + name: shop-config + key: Monitoring__Address + - name: Monitoring__ServiceName + valueFrom: + configMapKeyRef: + name: shop-config + key: Monitoring__ServiceName + - name: Monitoring__ApiKey + valueFrom: + secretKeyRef: + name: shop-secrets + key: aspire-apikey + volumeMounts: + - name: data + mountPath: /app/wwwroot/content + resources: + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + volumes: + - name: data + persistentVolumeClaim: + claimName: shop-data-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: shop-service + namespace: litecharms-shopadmin-uat +spec: + type: ClusterIP + selector: + app: shop + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 8080 +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: shop-web-secure + namespace: litecharms-shopadmin-uat +spec: + entryPoints: + - websecure + routes: + - match: Host(`shop.uat.khongisa.co.za`) + kind: Rule + services: + - name: shop-service + port: 80 + sticky: + cookie: + name: "lp-sticky-session" + httpOnly: true + secure: true + tls: {} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..87c9c3d --- /dev/null +++ b/nuget.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file