diff --git a/.agents/skills/interface-design/SKILL.md b/.agents/skills/interface-design/SKILL.md new file mode 100644 index 000000000..70d8043c8 --- /dev/null +++ b/.agents/skills/interface-design/SKILL.md @@ -0,0 +1,152 @@ +--- +name: interface-design +description: Teach, design, analyze, or critique professional digital interfaces from first principles. Use for university-style design education, visual foundations, typography, color, composition, grids, perception, HCI, user research, information architecture, interaction design, responsive behavior, accessibility, motion, content design, design systems, data-dense interfaces, ecommerce, ethical AI interfaces, studio exercises, and evidence-based design critique. Apply when the user wants reasoning and transferable design knowledge rather than a framework-specific implementation recipe or a tool workflow. +--- + +# Interface Design + +Use this skill as a studio teacher and practicing interface designer. Ground decisions in +formal design, perception, human-computer interaction, research, accessibility, and context. +Do not reduce design to visual fashion, component-library defaults, or conversion tactics. + +## Operating modes + +Infer the mode from the request: + +- **Teach**: explain a principle, demonstrate it, assign a bounded exercise, then critique. +- **Design**: frame the problem, explore alternatives, select a direction, specify behavior, + and define how to evaluate it. +- **Critique**: describe evidence before judgment; separate formal, semantic, interaction, + accessibility, and system-level findings. +- **Mentor**: ask the learner to make and defend decisions; give prompts and constraints + instead of immediately supplying the answer. +- **Curriculum**: sequence exercises from formal studies to complete interactive systems. + +Respond in the user's language. Preserve canonical English terms in parentheses the first +time they are useful for further study. + +## Core method + +1. **Establish context** + - Identify the people, situation, task, content, platform, constraints, and consequences. + - Distinguish observed evidence, supplied facts, assumptions, and hypotheses. + - Do not invent research findings, user needs, metrics, or brand attributes. + +2. **Frame the design problem** + - Express the desired human outcome, not merely the requested screen. + - Map information and tasks before choosing components. + - Define what success and failure would look like. + +3. **Explore** + - Generate materially different structural concepts, not cosmetic variants. + - Use constraints to force learning: one typeface, grayscale, no icons, keyboard only, + or one primary action. + - Compare alternatives by purpose, usability, accessibility, coherence, and cost. + +4. **Resolve** + - Establish hierarchy, reading order, layout logic, type system, color roles, states, + transitions, responsive transformations, and content. + - Prefer semantic and platform-familiar behavior unless deviation has a tested benefit. + - Treat empty, loading, partial, error, permission, offline, success, and destructive + states as part of the design. + +5. **Evaluate** + - Use critique, heuristic evaluation, accessibility review, and usability testing as + different methods with different evidence. + - State the confidence and source behind consequential recommendations. + - Propose the smallest test that could disprove the most important assumption. + +6. **Teach through the result** + - Name the principle. + - Explain the perceptual or behavioral mechanism. + - Show the consequence in this specific context. + - Name a plausible alternative and its tradeoff. + - Give an observable criterion for judging the result. + +## Reference routing + +Read only the modules needed for the task, but always read `references/00-source-policy.md` +when making claims or compiling learning material. + +- Formal language, composition, hierarchy, Gestalt: + `references/01-visual-language-and-composition.md` +- Type anatomy, typesetting, hierarchy, responsive type: + `references/02-typography.md` +- Color perception, roles, contrast, themes: + `references/03-color.md` +- Grids, spacing, density, responsive transformation: + `references/04-layout-grids-and-responsive-design.md` +- Attention, memory, mental models, motor behavior: + `references/05-perception-cognition-and-human-factors.md` +- Human-centered process, interviews, observation, synthesis, testing: + `references/06-research-and-human-centered-design.md` +- Taxonomy, navigation, search, flows, progressive disclosure: + `references/07-information-architecture-and-flows.md` +- Affordances, feedback, control, errors, modes, state: + `references/08-interaction-design.md` +- Controls, forms, tables, state matrices, component anatomy: + `references/09-components-forms-and-states.md` +- Tokens, components, governance, contribution, documentation: + `references/10-design-systems.md` +- WCAG, inclusive design, keyboard, assistive technology: + `references/11-accessibility-and-inclusive-design.md` +- Temporal hierarchy, transitions, easing, reduced motion: + `references/12-motion.md` +- Labels, instructions, errors, empty states, localization: + `references/13-content-design.md` +- Dashboards, data tables, visualization, ecommerce: + `references/14-data-dense-and-commerce-interfaces.md` +- Critique language, studio process, rubrics: + `references/15-critique-and-studio-practice.md` +- Privacy, manipulation, responsible and AI-mediated interfaces: + `references/16-ethics-and-ai-interfaces.md` +- Progressive exercises and capstone: + `references/17-curriculum-and-exercises.md` +- Canonical books, papers, standards, and living systems: + `references/18-bibliography.md` + +## Evidence discipline + +- Prefer standards and peer-reviewed or university sources for normative and scientific + claims. +- Prefer official platform guidance for platform conventions. +- Prefer independently tested research for behavioral claims. +- Treat design-system documentation as worked examples, not universal law. +- Treat named "laws" as models with boundary conditions, not natural constants. +- Never fabricate a citation. Use the canonical URL recorded in the reference modules. +- Distinguish `must` (standard or hard constraint), `should` (strong evidence or convention), + and `could` (contextual option). +- Do not copy long passages from sources. Synthesize, attribute, and link. + +## Critique standard + +Start with the intended outcome and evidence available. Then evaluate: + +1. **Concept**: Is there a coherent idea tied to the subject and audience? +2. **Information**: Can people find, understand, and prioritize what matters? +3. **Form**: Do composition, type, color, imagery, and space produce the intended hierarchy? +4. **Interaction**: Are actions discoverable, predictable, responsive, reversible, and complete? +5. **Inclusion**: Does the design survive keyboard use, zoom, text scaling, reduced motion, + localization, low vision, cognitive load, and constrained environments? +6. **System**: Are repeated decisions encoded consistently without erasing meaningful context? +7. **Evidence**: Which judgments are observed, which are standards-based, and which need testing? + +For each material finding provide: observation, consequence, principle, recommendation, +alternative, and verification method. Avoid taste-only verdicts such as "cleaner" or "more +modern" unless the formal change and intended effect are made explicit. + +## Educational standard + +When teaching, do not turn references into a list of rules. Require making, comparison, +articulation, and revision. A useful lesson contains: + +- a narrow learning objective; +- a short conceptual explanation; +- precedents to analyze; +- a constrained making exercise; +- a critique rubric; +- a revision pass; +- a transfer question that applies the principle to a different interface. + +Do not claim that reading this library replaces studio instruction, user contact, or practice. +Its purpose is to make the agent a more rigorous teacher and design partner. diff --git a/.agents/skills/interface-design/agents/openai.yaml b/.agents/skills/interface-design/agents/openai.yaml new file mode 100644 index 000000000..a8aad8096 --- /dev/null +++ b/.agents/skills/interface-design/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Interface Design" + short_description: "University-level foundations for interface design" + default_prompt: "Use $interface-design to teach, design, or critique an interface from first principles." diff --git a/.agents/skills/interface-design/references/00-source-policy.md b/.agents/skills/interface-design/references/00-source-policy.md new file mode 100644 index 000000000..f4380ce05 --- /dev/null +++ b/.agents/skills/interface-design/references/00-source-policy.md @@ -0,0 +1,112 @@ +# Source policy and authority map + +## Contents + +- Authority tiers +- How to use sources +- Currency and conflict handling +- Core source map +- Citation format + +## Authority tiers + +Use sources in this order when the claim types overlap. + +### Tier 1: normative standards and public institutions + +Use for requirements, definitions, and accessibility conformance. + +- [W3C Web Content Accessibility Guidelines (WCAG) overview](https://www.w3.org/WAI/standards-guidelines/wcag/) +- [W3C ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) +- [ISO 9241-210:2019 — Human-centred design for interactive systems](https://www.iso.org/standard/77520.html) +- [US Web Design System](https://designsystem.digital.gov/) +- [GOV.UK Design System](https://design-system.service.gov.uk/) + +Normative wording belongs only to the actual standard. W3C explicitly describes APG as +informative rather than normative. Do not present an APG example as the only conforming +implementation. + +### Tier 2: universities, peer-reviewed research, and scholarly curricula + +Use for foundations, theory, methods, and educational sequence. + +- [Carnegie Mellon School of Design BDes learning outcomes](https://www.design.cmu.edu/about-our-programs/undergraduate-degrees/learning-outcomes-bdes-degree-program) +- [CMU Master of Design in Design for Interactions curriculum](https://www.design.cmu.edu/about-our-programs/masters-degrees/master-design-design-interactions) +- [CMU Interaction Design Fundamentals course description](https://metals.hcii.cmu.edu/curriculum/) +- [Rhode Island School of Design Graphic Design BFA](https://www.risd.edu/academics/graphic-design/bachelors-program) +- [Yale Graphic Design program](https://www.art.yale.edu/about/study-areas/graduate-study-areas/graphic-design) +- [Yale Preliminary Studio: Graphic Design](https://www.art.yale.edu/art710a) +- [MIT OpenCourseWare: Art of Color](https://ocw.mit.edu/courses/es-298-art-of-color-spring-2005/pages/syllabus/) +- [MIT OpenCourseWare: User Interface Design and Implementation](https://ocw.mit.edu/courses/6-831-user-interface-design-and-implementation-spring-2011/) +- [UC San Diego Design and Interaction curriculum](https://cogsci.ucsd.edu/undergraduates/major/design-interaction.html) +- [ACM Digital Library](https://dl.acm.org/) + +Do not convert a syllabus topic into a scientific fact. Use the syllabus to justify curriculum +coverage and consult original research for empirical claims. + +### Tier 3: official platform and mature design-system guidance + +Use for platform conventions, implementation-tested patterns, and examples of systemization. + +- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) +- [Material Design 3](https://m3.material.io/) +- [Microsoft Fluent 2](https://fluent2.microsoft.design/) +- [IBM Carbon Design System](https://carbondesignsystem.com/) +- [GOV.UK Service Manual](https://www.gov.uk/service-manual) + +These sources express the goals and constraints of their organizations. Extract transferable +reasoning, but do not turn brand-specific values, token scales, or component anatomy into +universal rules. + +### Tier 4: established research and professional organizations + +Use for synthesized guidance and large observational datasets. + +- [Nielsen Norman Group](https://www.nngroup.com/articles/) +- [Baymard Institute research](https://baymard.com/research) +- [Microsoft Inclusive Design](https://inclusive.microsoft.design/) +- [IDEO Design Kit](https://www.designkit.org/) + +Record research method, population, product category, and date when using findings. A checkout +finding does not automatically generalize to a clinical workflow or creative tool. + +### Tier 5: canonical books and practitioner sources + +Use for durable vocabulary, historical context, and craft knowledge. Prefer original authors. +The bibliography records recommended works. Do not reproduce copyrighted chapters or diagrams. + +## How to use sources + +For each consequential claim: + +1. Classify it as normative, empirical, theoretical, platform conventional, or heuristic. +2. Choose the highest appropriate authority tier, not simply the most famous source. +3. State boundary conditions and uncertainty. +4. Link to the source closest to the original claim. +5. Separate source-derived guidance from the designer's contextual inference. + +Example: + +- Weak: "Users prefer fewer checkout fields." +- Better: "Baymard's ecommerce usability studies repeatedly find unnecessary checkout fields + increase friction; verify which fields are unnecessary for this market and fulfillment model." + +## Currency and conflicts + +- Attach version numbers to standards: WCAG 2.2, ISO 9241-210:2019. +- Treat platform guidance as living documentation and recheck it for current work. +- When two systems conflict, identify their different contexts before choosing. +- When a current trend conflicts with accessibility or task clarity, the constraint wins. +- When evidence is weak or unavailable, label the decision a hypothesis and propose a test. + +## Citation format + +Use inline Markdown links near the claim: + +```markdown +WCAG 2.2 defines minimum target-size and focus-visibility criteria +([W3C](https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/)). +``` + +For learning modules, finish with a `Sources and further study` section. Use descriptive link +labels rather than raw URLs. Include access dates only when a volatile page or metric matters. diff --git a/.agents/skills/interface-design/references/01-visual-language-and-composition.md b/.agents/skills/interface-design/references/01-visual-language-and-composition.md new file mode 100644 index 000000000..f5f671dc1 --- /dev/null +++ b/.agents/skills/interface-design/references/01-visual-language-and-composition.md @@ -0,0 +1,187 @@ +# Visual language and composition + +## Contents + +- Purpose of formal study +- Visual variables +- Relationships and Gestalt +- Hierarchy and reading +- Composition systems +- Interface application +- Studio exercises +- Critique rubric +- Sources + +## Purpose of formal study + +Formal design is the organization of perceptible relationships. It is not decoration added +after a workflow is solved. Size, position, interval, direction, value, color, texture, and +motion determine what appears related, important, actionable, stable, or in conflict. + +University foundation programs repeatedly begin with form, image, color, typography, and +composition before specialized media. RISD describes these as analytical, formal, sensory, +and technical foundations; Yale's preliminary studio joins them with conceptual development +and critique. + +Study form separately from content to sharpen perception, then reunite them. A formally strong +composition that misrepresents meaning is still a failed interface. + +## Visual variables + +Analyze an element through independent variables: + +- **Position**: where it sits in the field and relative to anchors. +- **Scale**: its relative size, not merely its numeric dimensions. +- **Shape**: boundary, silhouette, and internal structure. +- **Value**: perceived lightness or darkness. +- **Hue and chroma**: color family and intensity. +- **Orientation**: direction and implied movement. +- **Texture**: visual frequency and surface quality. +- **Space**: the intervals within and around elements. +- **Depth**: overlap, occlusion, shadow, blur, perspective, and layer. +- **Time**: sequence, duration, rhythm, and change. + +Use more than one variable for critical distinctions. A validation error that differs only by +red color is fragile; shape, iconography, wording, and location can reinforce it. + +## Relationships and Gestalt + +Gestalt principles are useful descriptions of perceptual grouping, not a substitute for user +research or a license to claim universal perception. + +- **Proximity**: smaller intervals imply stronger grouping. +- **Similarity**: shared form, color, orientation, or behavior implies a class. +- **Common region**: a boundary groups enclosed elements. +- **Connectedness**: a visible connection usually overrides proximity. +- **Continuity**: aligned trajectories are read as related paths. +- **Closure**: incomplete boundaries may be perceived as complete forms. +- **Figure-ground**: contrast and enclosure influence what appears foreground or background. +- **Common fate**: elements moving together appear related. + +In interfaces, spacing is usually a quieter and more flexible grouping mechanism than adding +boxes and dividers. Use containers when the boundary itself communicates state, interaction, +scope, or persistence. + +## Hierarchy and reading + +Hierarchy is an ordered set of differences that directs attention and supports comprehension. +It can be built with: + +- scale and weight; +- spatial position and sequence; +- contrast and value; +- density and isolation; +- alignment and indentation; +- repetition and exception; +- motion and timing; +- semantic labeling. + +Do not ask whether a heading is "big enough." Ask whether the intended order remains apparent +under squinting, grayscale, zoom, localization, and content variation. + +### Practical hierarchy test + +1. Blur or squint at the composition. +2. List the first five masses or anchors perceived. +3. Compare that sequence with the task priority. +4. Remove color and repeat. +5. Replace production copy with realistic long content and repeat. + +The test reveals hierarchy, not usability. It cannot determine whether the chosen priorities +match user goals. + +## Composition systems + +### Axis and alignment + +Alignment creates continuity and reduces the number of positions the eye must interpret. +Choose a small number of meaningful axes. Optical alignment may differ from geometric +alignment, especially for icons, round forms, punctuation, and mixed type sizes. + +### Balance + +- **Symmetrical balance** communicates stability, ceremony, and equality. +- **Asymmetrical balance** uses unequal masses held by position, contrast, or space. +- **Radial organization** emphasizes a center and is rarely appropriate for routine data work. + +Balance is not the equal distribution of objects. It is a perceived equilibrium of visual +forces. + +### Rhythm + +Rhythm emerges through repeated intervals, sizes, forms, and changes. Uniform repetition +supports predictability; controlled variation establishes phrase and emphasis. Accidental +near-repetition reads as inconsistency. + +### Contrast + +Contrast is difference with a communicative job. Increase contrast to separate roles; reduce it +to establish continuity. Excessive contrast everywhere destroys priority. + +### Negative space + +Empty space is active structure. It separates groups, gives important elements territory, +establishes tempo, and makes dense information interpretable. Large amounts of space do not +automatically create elegance; they can also hide relationships and reduce throughput. + +### Tension and movement + +Cropping, diagonals, off-center mass, edge proximity, and interrupted patterns generate visual +tension. Use tension when it supports the subject or attention goal. Routine operational +interfaces generally need stable landmarks more than continuous visual drama. + +## Interface application + +- Make the content and task model determine groups before drawing cards. +- Give the primary action priority through placement, language, and availability, not only color. +- Keep repeated components structurally stable; reserve exceptions for meaningful state. +- Design with real nouns, numbers, labels, and edge cases. Placeholder rectangles conceal + typographic and information problems. +- Treat chrome as supporting structure. Product content should usually dominate navigation + containers, borders, and decoration. +- Use familiar component forms when learnability matters; use visual distinctiveness where it + strengthens identity without altering expected behavior. + +## Studio exercises + +### Exercise 1: black-and-white hierarchy + +Design the same information in three 800 × 1000 compositions using only black, white, one +typeface, and four type sizes. No icons, borders, or images. + +Learning objective: create hierarchy with scale, weight, alignment, and space. + +### Exercise 2: grouping without boxes + +Rebuild a card-heavy settings screen without containers. Use spacing, alignment, typography, +and dividers only where a boundary carries meaning. Then compare scanability and scope. + +### Exercise 3: nine compositions + +Create nine structurally different arrangements for one interface state. Vary dominant axis, +density, balance, and focal point. Cosmetic changes do not count. + +### Exercise 4: semantic inversion + +Take a visually polished screen and intentionally make formal hierarchy contradict semantic +priority. Identify how each variable produces the deception, then repair it. + +## Critique rubric + +- Can the first three levels of importance be named without reading every word? +- Do proximity and boundaries express the actual information model? +- Are alignment systems intentional and few? +- Is contrast concentrated where decisions or state require it? +- Does negative space support grouping rather than merely lower density? +- Are exceptions meaningful or decorative? +- Does the composition survive realistic copy, localization, zoom, and different data states? + +## Sources and further study + +- [MIT Visual Communication Fundamentals course description](https://firstyear.mit.edu/wp-content/uploads/2025/02/Course-4-2025.pdf) +- [RISD Graphic Design BFA curriculum](https://www.risd.edu/academics/graphic-design/bachelors-program) +- [Yale Preliminary Studio: Graphic Design](https://www.art.yale.edu/art710a) +- [CMU Communication Design program](https://design.cmu.edu/about-our-programs/undergraduate-degrees/communications) +- Ellen Lupton and Jennifer Cole Phillips, *Graphic Design: The New Basics*. +- Josef Müller-Brockmann, *Grid Systems in Graphic Design*. +- Wucius Wong, *Principles of Form and Design*. diff --git a/.agents/skills/interface-design/references/02-typography.md b/.agents/skills/interface-design/references/02-typography.md new file mode 100644 index 000000000..df7f9c930 --- /dev/null +++ b/.agents/skills/interface-design/references/02-typography.md @@ -0,0 +1,185 @@ +# Typography for interfaces + +## Contents + +- Typography as structure +- Anatomy and classification +- Typesetting +- Hierarchy and systems +- Screen and responsive typography +- Selection and pairing +- Interface-specific guidance +- Exercises and rubric +- Sources + +## Typography as structure + +Typography gives language visible form. It influences reading order, pace, tone, density, +affordance, and trust. Treat it as the primary interface material, not a styling token chosen +after layout. + +RISD's typography sequence moves from letterform construction to word, paragraph, and page. +Use the same progression for interfaces: glyph quality, word shape, line, text block, component, +screen, and system. + +## Anatomy and classification + +Know enough anatomy to explain observed behavior: + +- baseline, cap height, x-height; +- ascender and descender; +- stem, stroke, counter, aperture, terminal, serif; +- width, contrast, stress, optical size; +- roman, italic, oblique; +- text and display cuts; +- variable axes such as weight, width, slant, grade, and optical size. + +Classification is a vocabulary, not a quality ranking. Serif, sans, grotesk, humanist, +geometric, transitional, and monospace families carry histories and formal tendencies, but +their actual drawing and setting matter more than the label. + +## Typesetting + +### Measure + +Line length must suit type size, language, reading mode, and context. Avoid treating a fixed +character count as a universal law. Long-form reading usually benefits from a constrained +measure; labels and data cells obey different constraints. + +### Leading + +Line spacing creates vertical rhythm and affects the reader's ability to return to the next +line. Increase leading for long lines, large x-heights, dense languages, and text that must +remain readable under difficult conditions. Display type may use tighter leading if collisions +and diacritics are tested. + +### Tracking and kerning + +- Use tracking to tune the texture of a span, not to repair a poor typeface. +- Avoid loose tracking in lowercase body text. +- Uppercase labels often need additional tracking, but sentence case is usually faster to read + and localizes more gracefully. +- Inspect kerning in prominent display text and numerals; do not manually kern routine UI copy. + +### Alignment + +- Left-align continuous left-to-right text by default. +- Right-align continuous right-to-left text. +- Center short, isolated statements only when the composition benefits from a central axis. +- Right-align numbers in comparison columns and align decimals when precision matters. +- Avoid justified interface text unless language-specific hyphenation and spacing are controlled. + +### Typographic color + +Typographic color is the perceived density and texture of a text block. Weight, size, leading, +measure, letterspacing, and paragraph spacing all contribute. Judge blocks at reading distance, +not only individual glyphs at high zoom. + +## Hierarchy and systems + +Build a semantic type system before naming pixel values: + +- display or campaign expression; +- page title; +- section title; +- component title; +- body; +- supporting body; +- label; +- caption or metadata; +- data or code. + +Use the fewest levels that preserve meaning. Differentiate levels with a controlled combination +of size, weight, line height, spacing, and color. If every level differs on every variable, the +system becomes hard to learn and maintain. + +Prefer semantic roles over names such as `font-16-bold`. A role can adapt across viewport, +platform, locale, and accessibility settings without changing meaning. + +## Screen and responsive typography + +- Support browser zoom and user text-size preferences. +- Avoid locking text in containers with fixed heights. +- Reflow layout when text grows; do not solve scaling with truncation. +- Test at narrow width, 200% zoom, long translations, and large accessibility sizes. +- Use relative units and optical sizes where the platform supports them. +- Pair icons with type by optical size, baseline, weight, and meaning. +- Preserve hierarchy when scale changes. A large-text mode is not simply every number multiplied. + +Apple recommends system text styles and Dynamic Type so hierarchy and legibility adapt to user +settings. Fluent demonstrates how one semantic ramp changes across web, Windows, macOS, iOS, +and Android. These are system examples, not universal numeric prescriptions. + +## Selection and pairing + +Choose a typeface by: + +- language and script coverage; +- legibility at target sizes and displays; +- family breadth and variable axes; +- numeral, punctuation, symbol, and currency quality; +- tone appropriate to subject and audience; +- rendering and performance; +- licensing and long-term availability. + +Pair only when roles require a meaningful contrast. A single family with optical sizes, widths, +or contrasting weights is often more coherent than a fashionable pairing. + +Do not ban or prescribe typefaces by popularity alone. Generic output comes from unexamined +decisions, not from a font name. If a neutral system font best serves a high-frequency tool, +use it deliberately. + +## Interface-specific guidance + +- Write button labels as actions when they perform actions. +- Give input labels persistent visible space; placeholders are examples, not labels. +- Use tabular numerals for changing values and aligned comparisons. +- Distinguish identifiers, user content, metadata, and system status. +- Avoid low-contrast "muted" text that still contains required information. +- Let content determine component width and wrapping behavior. +- Establish explicit rules for truncation, wrapping, expansion, and full-value access. +- Test mixed scripts, diacritics, emoji, bidirectional text, dates, units, and currencies. + +## Exercises + +### Typesetting ladder + +Set one 500-word article at six measures. Adjust size and leading until each setting forms a +coherent text color. Annotate what changed and why. + +### One-family interface + +Design an entire data-rich screen with one family and no color hierarchy. Use only size, +weight, width, case, and spacing. + +### Localization stress test + +Replace concise English labels with realistic German, Ukrainian, Arabic, and Japanese strings. +Repair the system without shrinking all type. + +### Numeral study + +Design a financial table using proportional, tabular, oldstyle, and lining figures. Explain +which combination supports scanning, comparison, and prose. + +## Critique rubric + +- Does the typeface support the languages and symbols required? +- Is hierarchy visible but economical? +- Are body measure, leading, and weight comfortable in context? +- Do labels and controls retain meaning under wrapping and scaling? +- Are numerical comparisons aligned appropriately? +- Does the system preserve hierarchy at zoom and large text sizes? +- Is the choice justified by content, task, platform, and brand rather than trend? + +## Sources and further study + +- [RISD Typography I course description](https://www.risd.edu/academics/graphic-design/courses?page=1) +- [Apple Human Interface Guidelines: Typography](https://developer.apple.com/design/human-interface-guidelines/typography) +- [Microsoft Fluent 2: Typography](https://fluent2.microsoft.design/typography) +- [W3C WCAG 2.2](https://www.w3.org/TR/WCAG22/) +- [W3C Understanding text spacing](https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html) +- [Material Design 3: Typography](https://m3.material.io/styles/typography/overview) +- Ellen Lupton, *Thinking with Type*. +- Robert Bringhurst, *The Elements of Typographic Style*. +- Sofie Beier, *Reading Letters: Designing for Legibility*. diff --git a/.agents/skills/interface-design/references/03-color.md b/.agents/skills/interface-design/references/03-color.md new file mode 100644 index 000000000..1921d143b --- /dev/null +++ b/.agents/skills/interface-design/references/03-color.md @@ -0,0 +1,151 @@ +# Color for interfaces + +## Contents + +- Color as perception +- Dimensions and relationships +- Functional color systems +- Contrast and accessibility +- Themes and adaptation +- Data and status color +- Exercises and rubric +- Sources + +## Color as perception + +Color is relational. The same physical color can appear different depending on surrounding +value, hue, area, illumination, display, and adaptation. Do not judge a swatch in isolation. + +MIT's Art of Color course combines studio studies of interaction, balance, expression, and +warm-cool relationships with the physics and neuroscience of color. Preserve that dual view: +color is both measurable stimulus and situated experience. + +## Dimensions and relationships + +Use a perceptual vocabulary: + +- **Hue**: perceived color family. +- **Lightness/value**: perceived brightness relative to reference. +- **Chroma/saturation**: perceived colorfulness or distance from neutral. +- **Temperature**: contextual warm-cool relation, not an absolute property. +- **Area**: larger areas amplify the apparent presence of a color. +- **Simultaneous contrast**: surrounding colors change apparent hue and value. +- **Successive contrast/adaptation**: prior exposure influences subsequent perception. + +Prefer perceptually organized color spaces such as OKLCH for systematic generation, but validate +the final rendered colors and contrast in target browsers and devices. A convenient color space +does not guarantee accessible or harmonious results. + +## Functional color systems + +Define color by role before value: + +- canvas and surfaces; +- primary and secondary text; +- borders and separators; +- interactive accent; +- focus; +- selected and current; +- informational, success, warning, critical; +- data series and categorical distinctions; +- scrims, overlays, and elevation cues. + +Separate brand expression from interface semantics. If the brand accent also means "selected," +"link," and "success," the system becomes ambiguous. + +Use tokens that express intent: + +```text +color.text.primary +color.action.primary.background +color.status.critical.text +``` + +Avoid tokens such as `blue500` at the semantic layer. Palette tokens may exist underneath, but +components should bind to roles that can change with theme and context. + +## Contrast and accessibility + +WCAG contrast is a minimum conformance test, not a complete model of readability. + +- Test text and essential graphics against every background they can occupy. +- Do not use color as the only carrier of state or meaning. +- Keep focus indicators perceptible against adjacent colors. +- Test disabled states without making necessary information unreadable. +- Evaluate gradients, images, transparency, materials, hover states, and dark themes at their + weakest contrast point. +- Consider glare, low-quality displays, brightness settings, color-vision differences, and + forced-colors modes. + +Use the current WCAG 2.2 criteria for conformance. If evaluating APCA, identify it as a different +contrast model and do not substitute experimental thresholds for a required WCAG audit unless +the governing policy permits it. + +## Themes and adaptation + +Dark mode is not palette inversion. + +- Re-establish hierarchy for the new adaptation state. +- Reduce large areas of high-chroma color. +- Tune borders and elevation because shadows behave differently on dark surfaces. +- Preserve semantic identity while changing lightness and chroma. +- Test images, illustrations, charts, syntax, and third-party content. +- Support system preference while permitting a user override when appropriate. + +High-contrast and forced-color modes are distinct from dark mode. Do not disable system color +adjustments merely to protect brand appearance. + +## Data and status color + +- Use categorical palettes for unordered classes and sequential palettes for ordered magnitude. +- Use diverging palettes only when a meaningful midpoint exists. +- Preserve sufficient lightness differences, not only hue differences. +- Label important values directly where possible. +- Keep semantic status colors stable across the product. +- Avoid the rainbow palette for ordered data; its uneven perceptual steps invent boundaries. +- Do not assume red and green carry the same cultural meaning everywhere or are distinguishable + by everyone. + +## Exercises + +### Value-first palette + +Design a complete interface in grayscale. Establish hierarchy and state. Add hue only where it +improves meaning, navigation, or identity. Document each addition. + +### Simultaneous contrast study + +Place the same center color on nine backgrounds. Record perceived changes before measuring. +Translate the lesson into a status badge used on multiple surfaces. + +### Semantic theme + +Create light, dark, and high-contrast mappings for the same semantic tokens. Preserve meaning +without forcing identical numeric relationships. + +### Color-independent state + +Redesign success, warning, error, selected, and disabled states so each remains understandable +in grayscale and under common color-vision simulations. + +## Critique rubric + +- Does color encode defined roles rather than arbitrary decoration? +- Is hierarchy still functional without hue? +- Are critical distinctions redundant with text, shape, or position? +- Does every color work across themes, states, and backgrounds? +- Are data palettes appropriate to the data relationship? +- Are cultural meanings and platform conventions considered? +- Has contrast been tested rather than estimated? + +## Sources and further study + +- [MIT OpenCourseWare: Art of Color](https://ocw.mit.edu/courses/es-298-art-of-color-spring-2005/pages/syllabus/) +- [W3C WCAG 2.2 contrast criteria](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) +- [Apple Human Interface Guidelines: Color](https://developer.apple.com/design/human-interface-guidelines/color) +- [Material Design 3: Color system](https://m3.material.io/styles/color/overview) +- [IBM Carbon: Accessibility and color](https://carbondesignsystem.com/guidelines/accessibility/color/) +- [CIE — International Commission on Illumination](https://cie.co.at/) +- Josef Albers, *Interaction of Color*. +- Johannes Itten, *The Art of Color*. +- Maureen Stone, *A Field Guide to Digital Color*. diff --git a/.agents/skills/interface-design/references/04-layout-grids-and-responsive-design.md b/.agents/skills/interface-design/references/04-layout-grids-and-responsive-design.md new file mode 100644 index 000000000..7c1d92b9c --- /dev/null +++ b/.agents/skills/interface-design/references/04-layout-grids-and-responsive-design.md @@ -0,0 +1,176 @@ +# Layout, grids, spacing, and responsive design + +## Contents + +- Layout as relationships +- Grid families +- Spacing and density +- Responsive transformation +- Containers and depth +- Interface application +- Exercises and rubric +- Sources + +## Layout as relationships + +Layout gives information spatial logic. It should reveal sequence, grouping, comparison, scope, +and control. Start with content relationships and task priority; choose a grid that supports +them. Do not begin with a fashionable column count. + +Fluent describes space as a means of creating relationships, hierarchy, and decision comfort. +Its 4-unit ramp is one implementation example. The transferable principle is a limited, +meaningful spacing vocabulary with optical exceptions, not universal allegiance to four pixels. + +## Grid families + +- **Manuscript grid**: one dominant text area; useful for reading and focused forms. +- **Column grid**: flexible vertical alignment for heterogeneous content. +- **Modular grid**: columns plus rows; useful for repeated data, comparison, and dashboards. +- **Baseline grid**: aligns text rhythm across regions. +- **Hierarchical grid**: custom regions organized by content priority rather than equal modules. + +Every grid has margins, columns or regions, gutters, and alignment rules. Document when elements +may break the grid and what meaning the break carries. + +### Fixed, fluid, and hybrid behavior + +- Fixed regions preserve stable tool dimensions but can waste or steal space. +- Fluid regions adapt continuously but need min/max constraints. +- Hybrid layouts combine stable controls with flexible content. + +Choose behavior per region. A single screen may have a fixed navigation rail, resizable data +table, and bounded reading column. + +## Spacing and density + +Create at least three conceptual layers: + +- **micro spacing** inside controls and between tightly related elements; +- **component spacing** between elements in a repeated pattern; +- **layout spacing** between regions and major sections. + +Use tokens to reduce arbitrary variation, but make optical corrections where icons, type, or +shapes require them. Consistency is perceived, not achieved by blindly repeating the same number. + +Density is task-dependent: + +- High-frequency expert tools benefit from compactness, stable landmarks, keyboard access, and + user-adjustable density. +- Reading, onboarding, and high-consequence decisions benefit from focus and separation. +- Touch interfaces need adequate target size even when visual density is high. + +Do not equate spaciousness with quality or density with complexity. + +## Responsive transformation + +Responsive design is not shrinking a desktop canvas. For each region decide whether to: + +- resize; +- reflow; +- reposition; +- wrap; +- stack; +- collapse; +- disclose on demand; +- substitute a control; +- preserve through scrolling; +- remove only if nonessential to the task. + +Content and interaction breakpoints are preferable to a list of popular device widths. Introduce +a breakpoint when the current composition, reading measure, target size, or task relationship +fails. + +### Re-architecture + +Some interfaces require a different composition: + +- master-detail side by side becomes list then detail; +- persistent filters become a reviewable filter surface; +- comparison tables become prioritized attributes plus horizontal access; +- toolbars become grouped commands or a command palette. + +Preserve task continuity and state when changing architecture. Avoid hiding critical actions +without an equally discoverable replacement. + +### Text and localization + +Test: + +- 200% zoom; +- increased default font size; +- large accessibility text; +- long translations; +- right-to-left direction; +- variable date, number, address, and name formats; +- on-screen keyboard and safe-area changes. + +## Containers and depth + +Use a container when it communicates one of: + +- interaction boundary; +- selectable object; +- state or elevation; +- scope; +- drag or resize affordance; +- independent scrolling; +- persistence above another layer. + +Do not wrap every group in a card. Space and alignment often communicate grouping with less +visual noise. + +Depth should match behavior. A modal blocks context; a popover is anchored; a tooltip provides +transient explanation; a side panel may preserve workspace. Shadows, scrims, overlap, and motion +must agree with that model. + +## Interface application + +- Put important information in stable, predictable regions. +- Keep line length bounded inside wide fluid layouts. +- Align repeated labels and values to support comparison. +- Reserve full-bleed areas for content that benefits from scale. +- Avoid independent nested scrolling unless the task requires fixed comparative context. +- Specify overflow, minimum widths, wrapping, sticky behavior, and empty space behavior. +- Design the smallest and largest supported conditions, then inspect intermediate widths. + +## Exercises + +### Grid translation + +Lay out the same content using manuscript, column, modular, and hierarchical grids. Explain how +each changes reading and task behavior. + +### Responsive transformation matrix + +For every region of a complex desktop screen, specify its narrow-screen operation: preserve, +reflow, collapse, substitute, or remove. Prototype the transitions, not only endpoints. + +### Density study + +Create compact, comfortable, and spacious modes for a data table. Keep target size, hierarchy, +and readability defensible in each. + +### Box-removal pass + +Remove every border, shadow, and surface. Add back only boundaries that express behavior, +state, or scope. + +## Critique rubric + +- Does the grid follow content relationships? +- Are spacing intervals systematic but optically corrected? +- Does density match frequency, expertise, and input method? +- Are responsive changes driven by failure points rather than devices? +- Is important state preserved through transformation? +- Do containers correspond to real boundaries or behavior? +- Does the layout survive text growth, localization, zoom, and extreme data? + +## Sources and further study + +- [Microsoft Fluent 2: Layout](https://fluent2.microsoft.design/layout) +- [Apple Human Interface Guidelines: Layout](https://developer.apple.com/design/human-interface-guidelines/layout) +- [Material Design 3: Layout](https://m3.material.io/foundations/layout/understanding-layout/overview) +- [W3C WCAG 2.2: Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) +- [GOV.UK Design System styles](https://design-system.service.gov.uk/styles/) +- Josef Müller-Brockmann, *Grid Systems in Graphic Design*. +- Kimberly Elam, *Grid Systems*. diff --git a/.agents/skills/interface-design/references/05-perception-cognition-and-human-factors.md b/.agents/skills/interface-design/references/05-perception-cognition-and-human-factors.md new file mode 100644 index 000000000..7afe81d7e --- /dev/null +++ b/.agents/skills/interface-design/references/05-perception-cognition-and-human-factors.md @@ -0,0 +1,156 @@ +# Perception, cognition, and human factors + +## Contents + +- Scope and caution +- Attention and visual search +- Memory and recognition +- Mental models and mapping +- Decisions and cognitive load +- Motor behavior and input +- Error and expertise +- Exercises and rubric +- Sources + +## Scope and caution + +Human factors connects design to human capabilities, limitations, and context. Use models to +form hypotheses and explain mechanisms, not to declare that every person behaves identically. +Age, disability, culture, expertise, motivation, environment, and consequences change behavior. + +UC San Diego's Design and Interaction curriculum explicitly combines cognitive science, HCI, +prototyping, visual design, experimentation, and evaluation. This interdisciplinarity is a +better model than applying a list of named "UX laws." + +## Attention and visual search + +Attention is selective and limited. + +- Strong differences in color, orientation, size, or motion can make an item preattentively + salient, but salience does not guarantee comprehension. +- Competing salient elements divide attention and erase priority. +- Stable spatial landmarks support repeated use. +- Visual search is faster when targets have distinctive features and plausible location. +- Unexpected change outside the focus of attention can go unnoticed. +- Motion captures attention strongly; reserve it for consequential change or explanation. + +Design implication: make important state changes visible near the cause, preserve them long +enough to perceive, and avoid simultaneous unrelated animation. + +## Memory and recognition + +Working memory is limited and task-sensitive. Avoid quoting a universal item count as a UI law. +Complexity depends on chunking, familiarity, interference, and duration. + +- Prefer recognition over unaided recall for infrequent tasks. +- Keep needed information visible during comparison and multistep decisions. +- Preserve entered data and context across recoverable errors. +- Use meaningful groups and labels that match domain language. +- Let experienced users rely on learned shortcuts without hiding paths from novices. +- Do not force people to remember transient codes, totals, or rules between distant screens. + +Recognition is not always superior. Search, command palettes, and expert notation can be faster +than visually browsing a very large option space. + +## Mental models and mapping + +People predict a system from prior experience, visible cues, and feedback. + +- **Conceptual model**: the explanation the design offers for how the system works. +- **Mental model**: the user's evolving understanding. +- **Mapping**: relation between control and effect. +- **Affordance**: possible action in relation to an actor and environment. +- **Signifier**: perceivable cue indicating an affordance. + +Use familiar models when they fit. A metaphor that initially teaches but later constrains the +product should be replaced by a clear domain model. + +Natural mapping reduces translation: spatial controls correspond to spatial effects; the order +of fields follows the object or process people know; feedback appears near its source. + +## Decisions and cognitive load + +Reduce extraneous work, not meaningful choice. + +- Clarify distinctions between options. +- Supply defaults only when they are safe, reversible, and explainable. +- Stage information according to task sequence, while preserving access to consequences. +- Group related decisions and separate unrelated ones. +- Reveal advanced options without making essential behavior mysterious. +- Use comparison structures when people must weigh alternatives. +- Avoid false urgency and visually coercive defaults. + +Hick-Hyman and choice-reaction models describe experimental relationships between alternatives +and response time under conditions; they do not mean every menu should contain fewer items. +Organization, familiarity, frequency, and search strategy can matter more than raw count. + +## Motor behavior and input + +Fitts's law models acquisition time as a function of target distance and effective width. +Transferable implications: + +- Give frequent and consequential targets sufficient effective size. +- Use screen edges and corners deliberately where the platform makes them easy to acquire. +- Avoid placing destructive and frequent actions adjacent without separation or confirmation. +- Do not require precision for common touch actions. +- Support keyboard, pointer, touch, voice, switch, and other inputs where relevant. +- Account for tremor, limited dexterity, one-handed use, posture, movement, gloves, and glare. + +Do not infer a single target size from Fitts's law. Follow applicable platform and WCAG +requirements, then test the actual interaction. + +## Error and expertise + +- Slips occur when execution differs from intention. +- Mistakes occur when the intention or model is wrong. +- Prevention, feedback, and recovery must address the correct type. +- Experts benefit from accelerators, stable structure, batch actions, and low interaction cost. +- Novices benefit from clear concepts, examples, progressive guidance, and safe exploration. +- Confirmation dialogs are appropriate for rare, consequential, hard-to-reverse actions; they + become habituated noise when applied to everything. + +Design for agency: undo, version history, drafts, previews, and reversible operations often +protect better than warnings. + +## Exercises + +### Attention audit + +Record the first ten seconds of a new screen. Mark every element competing for attention and +every state change that can occur. Remove or sequence competition until task priority is clear. + +### Memory walk + +Complete a multistep flow while listing every fact the interface asks the user to remember. +Redesign to externalize necessary memory. + +### Model mismatch + +Find a product where internal system structure leaks into user-facing navigation. Draw the +system model and the user's task model, then propose a translation layer. + +### Input diversity + +Perform the same task with pointer, keyboard only, 200% zoom, and one-handed mobile use. Record +where target acquisition, focus, or context fails. + +## Critique rubric + +- What competes for attention, and is that competition intentional? +- What must be recalled instead of recognized or kept visible? +- Does the interface expose a coherent conceptual model? +- Are control-effect mappings visible and predictable? +- Is complexity intrinsic to the task or introduced by presentation? +- Are target size and placement appropriate to input and frequency? +- Does the design support both learning and efficient expert use? + +## Sources and further study + +- [UC San Diego B.S. Design and Interaction curriculum](https://cogsci.ucsd.edu/undergraduates/major/design-interaction.html) +- [MIT User Interface Design and Implementation](https://ocw.mit.edu/courses/6-831-user-interface-design-and-implementation-spring-2011/) +- [ISO 9241-210:2019](https://www.iso.org/standard/77520.html) +- [W3C cognitive accessibility](https://www.w3.org/WAI/cognitive/) +- Don Norman, *The Design of Everyday Things*. +- Christopher Wickens et al., *Engineering Psychology and Human Performance*. +- Stuart Card, Thomas Moran, and Allen Newell, *The Psychology of Human-Computer Interaction*. +- Colin Ware, *Information Visualization: Perception for Design*. diff --git a/.agents/skills/interface-design/references/06-research-and-human-centered-design.md b/.agents/skills/interface-design/references/06-research-and-human-centered-design.md new file mode 100644 index 000000000..2a0e36021 --- /dev/null +++ b/.agents/skills/interface-design/references/06-research-and-human-centered-design.md @@ -0,0 +1,202 @@ +# Research and human-centered design + +## Contents + +- Human-centered principles +- Research framing +- Method selection +- Interviews and observation +- Synthesis +- Prototyping and evaluation +- Research integrity +- Exercises and rubric +- Sources + +## Human-centered principles + +ISO 9241-210 frames human-centered design as activities across the life cycle of interactive +systems, not a workshop at the beginning. The transferable commitments are: + +- understand people, tasks, and environments explicitly; +- involve users throughout design and development; +- drive and refine design through user-centered evaluation; +- iterate; +- address the whole experience; +- use multidisciplinary perspectives. + +Human-centered does not mean satisfying every stated preference. Research helps interpret goals, +behavior, constraints, workarounds, and consequences; design still requires judgment. + +## Research framing + +Start with a decision, not a favorite method. + +```text +Decision: +What must the team choose? + +Current evidence: +What is known, from where, and with what confidence? + +Critical unknown: +What uncertainty could change the decision? + +Participants and context: +Whose experience and environment matter? + +Method: +What evidence can reduce that uncertainty? + +Analysis: +How will observations become findings? + +Limitations: +What will this study not establish? +``` + +Distinguish generative research, evaluative research, and measurement. Interviews about a +prototype are not usability testing; preference surveys are not behavioral evidence. + +## Method selection + +### Attitudinal vs behavioral + +- Attitudinal methods examine what people report: interviews, surveys, diary entries. +- Behavioral methods examine what people do: observation, usability testing, analytics. + +### Qualitative vs quantitative + +- Qualitative work explains patterns, mechanisms, language, and unmet needs. +- Quantitative work estimates magnitude, distribution, or comparative effect. + +### Context of use + +- Naturalistic: field study, contextual inquiry, diary study. +- Constructed: moderated test, concept evaluation, controlled experiment. + +Choose methods by uncertainty. Do not use analytics to explain motivation without corroboration, +or a handful of interviews to estimate population prevalence. + +## Interviews and observation + +### Interviews + +- Recruit for relevant behavior and context, not demographic convenience alone. +- Ask about specific past events before hypothetical future behavior. +- Invite stories, artifacts, sequence, exceptions, and workarounds. +- Avoid leading language and pitching the concept. +- Probe consequences: what happened next, who else was involved, what made it difficult? +- Record and obtain consent according to policy. + +### Contextual observation + +- Observe the real environment, tools, interruptions, collaboration, and unofficial practices. +- Ask participants to teach the work while doing it. +- Separate observed behavior from the researcher's interpretation. +- Treat workarounds as evidence about system mismatch, not user failure. + +### Sample limitations + +Small qualitative samples can reveal recurring usability problems and generate theory, but they +do not prove saturation or represent a market by default. State recruitment bias and missing +populations. + +## Synthesis + +Synthesis transforms evidence into a model that supports decisions. + +1. Normalize notes into observations with traceable source. +2. Cluster by behavior, cause, goal, barrier, or context—not by desired feature. +3. Look for contradictions and negative cases. +4. Form findings that connect evidence to consequence. +5. Express opportunities without prematurely fixing the solution. +6. Track confidence and open questions. + +Useful artifacts: + +- task analysis; +- mental-model diagram; +- journey map anchored in research; +- service blueprint; +- ecosystem map; +- jobs or outcome statements; +- research repository with evidence links. + +Personas are useful only when they encode consequential behavioral differences. Decorative +names, portraits, and invented biographies create false confidence. + +## Prototyping and evaluation + +Match fidelity to the question: + +- paper or text sequence for flow and concept; +- wireframe for structure and hierarchy; +- realistic interactive prototype for behavior and timing; +- production-like build for performance, accessibility, or integrated workflow. + +### Usability testing + +- Give realistic goals, not step-by-step instructions. +- Let participants attempt before helping. +- Ask neutral follow-ups after behavior. +- Record task success, critical incidents, errors, recovery, confidence, and qualitative evidence. +- Separate facilitator problems from interface problems. +- Prioritize findings by severity, frequency evidence, and consequence. + +Testing discovers problems in the tested design with the recruited participants and scenario. +It does not certify universal usability. + +## Research integrity + +- Obtain informed consent. +- Minimize collection and protect participant data. +- Compensate fairly. +- Avoid recruiting only easy or powerful populations. +- Include accessibility needs from the start. +- Do not fabricate quotes or convert interpretation into verbatim speech. +- Maintain an evidence trail from raw observation to finding to recommendation. +- Report limitations and dissenting evidence. +- Research with vulnerable populations may require formal ethical review. + +## Exercises + +### Method matching + +Given ten product questions, choose a method and explain what evidence it can and cannot provide. + +### Interview repair + +Rewrite a leading interview guide into neutral prompts about specific past behavior. + +### Evidence ladder + +Take five design claims and label them assumption, anecdote, qualitative pattern, quantitative +estimate, causal evidence, or standard requirement. + +### Prototype ladder + +Represent one concept at four fidelity levels. For each, define the question it can answer and +the false confidence it might create. + +## Critique rubric + +- Is the research tied to a real decision? +- Does the method match the uncertainty? +- Are participant and context boundaries explicit? +- Are observations separated from interpretation? +- Can every finding be traced to evidence? +- Are contradictions and limitations preserved? +- Is the prototype only as detailed as the question requires? +- Are consent, privacy, inclusion, and power considered? + +## Sources and further study + +- [ISO 9241-210:2019](https://www.iso.org/standard/77520.html) +- [CMU BDes learning outcomes](https://www.design.cmu.edu/about-our-programs/undergraduate-degrees/learning-outcomes-bdes-degree-program) +- [CMU MDes Design for Interactions](https://www.design.cmu.edu/about-our-programs/masters-degrees/master-design-design-interactions) +- [Nielsen Norman Group: UX research methods](https://www.nngroup.com/articles/which-ux-research-methods/) +- [IDEO Design Kit methods](https://www.designkit.org/methods) +- Bella Martin and Bruce Hanington, *Universal Methods of Design*. +- Hugh Beyer and Karen Holtzblatt, *Contextual Design*. +- Steve Portigal, *Interviewing Users*. +- David Travis and Philip Hodgson, *Think Like a UX Researcher*. diff --git a/.agents/skills/interface-design/references/07-information-architecture-and-flows.md b/.agents/skills/interface-design/references/07-information-architecture-and-flows.md new file mode 100644 index 000000000..37c900036 --- /dev/null +++ b/.agents/skills/interface-design/references/07-information-architecture-and-flows.md @@ -0,0 +1,195 @@ +# Information architecture and user flows + +## Contents + +- Scope +- Content modeling and taxonomy +- Navigation and orientation +- Search and retrieval +- Flows and state +- Progressive disclosure +- Exercises and rubric +- Sources + +## Scope + +Information architecture (IA) organizes information and actions so people can understand where +they are, what is available, and how to proceed. It includes content models, taxonomy, labels, +navigation, search, relationships, and wayfinding. + +Do not confuse the sitemap, URL tree, database schema, organization chart, and user mental model. +They may influence each other but serve different purposes. + +## Content modeling and taxonomy + +Before screens, identify: + +- entities and their attributes; +- relationships and cardinality; +- lifecycle and state; +- ownership and permissions; +- common user vocabulary and synonyms; +- facets used for filtering and comparison; +- canonical identifiers and display names. + +Taxonomies can be: + +- hierarchical; +- faceted; +- sequential; +- networked; +- task-based; +- audience-based; +- hybrid. + +Choose a structure that supports retrieval and maintenance. Deep hierarchy is not inherently +worse than broad hierarchy; test scent, ambiguity, frequency, and the cost of scanning. + +### Labels + +Labels should: + +- match user and domain language; +- distinguish neighboring choices; +- predict destination or effect; +- remain concise without becoming cryptic; +- localize and scale; +- avoid internal team terminology unless users share it. + +Use controlled vocabulary where consistency and retrieval matter, while supporting synonyms in +search. + +## Navigation and orientation + +Navigation must answer: + +1. Where am I? +2. What is this? +3. What can I do here? +4. Where can I go? +5. How do I return or recover? + +Establish current location through heading, selected navigation, breadcrumb where hierarchy is +meaningful, and persistent context. Do not rely on color alone. + +Global, local, contextual, utility, and in-content navigation serve different scopes. Keep +scope visually and behaviorally distinct. + +Avoid hiding primary destinations behind ambiguous icons or account menus. A hamburger menu is +a space-saving control, not an information architecture. + +## Search and retrieval + +Search behavior depends on domain and inventory: + +- exact known-item lookup; +- exploratory browsing; +- attribute comparison; +- re-finding; +- error-tolerant query; +- expert syntax. + +Design: + +- query entry and examples; +- autocomplete and suggestions; +- typo tolerance and synonyms; +- result ranking and explainability; +- filters and facets; +- zero-results recovery; +- recent and saved queries; +- visible active constraints; +- shareable and restorable state. + +Filters alter the result set; sorting changes order. Make the distinction clear and preserve +user selections through navigation. + +## Flows and state + +A user flow is not merely a sequence of screens. Model: + +- entry conditions; +- user intent; +- system and data prerequisites; +- decisions and branches; +- permission boundaries; +- validation; +- external actors or services; +- interruption and resumption; +- success, partial success, failure, cancellation, and recovery; +- exit conditions and downstream effects. + +Represent the happy path only after mapping failure and alternate paths. For consequential +workflows, show what is committed at each step and whether it can be undone. + +### State machines + +Use state models when behavior becomes ambiguous. Define: + +```text +state +event/action +guard or permission +transition +side effect +feedback +recovery +``` + +State models prevent visual mockups from silently contradicting product logic. + +## Progressive disclosure + +Progressive disclosure defers secondary complexity while preserving access and predictability. +It is appropriate when: + +- advanced options are infrequent; +- defaults are safe; +- revealing everything would obscure the main task; +- hidden information does not change informed consent or consequences. + +Do not hide prices, risks, eligibility, destructive effects, or necessary comparison data under +the banner of simplicity. + +## Exercises + +### Open card sort + +Create a realistic content set and let participants group and label it. Analyze agreement and +disagreement without forcing a single "correct" tree. + +### Tree test + +Test whether people can locate target information using labels and hierarchy without visual +design. + +### Flow stress map + +Take a simple purchase or publishing flow and add interruption, permission loss, expired data, +partial failure, and return after seven days. + +### Navigation without icons + +Redesign a navigation system using words and structure first. Add icons only where they improve +recognition or scanning. + +## Critique rubric + +- Does structure follow user tasks and content relationships? +- Are labels distinct, predictable, and in domain language? +- Can people orient without memorizing the path? +- Are search, filter, sort, and browse roles clear? +- Are state, branches, interruptions, and recovery represented? +- Is progressive disclosure hiding only secondary complexity? +- Can navigation and filtered states be restored and shared where appropriate? + +## Sources and further study + +- [CMU Communication Design program](https://design.cmu.edu/about-our-programs/undergraduate-degrees/communications) +- [Nielsen Norman Group: Information architecture](https://www.nngroup.com/articles/information-architecture-sitemap/) +- [Nielsen Norman Group: Card sorting](https://www.nngroup.com/articles/card-sorting-definition/) +- [GOV.UK Service Manual: Structuring information](https://www.gov.uk/service-manual/design) +- [Baymard ecommerce search research](https://baymard.com/research/ecommerce-search) +- Louis Rosenfeld, Peter Morville, and Jorge Arango, *Information Architecture for the Web and Beyond*. +- Abby Covert, *How to Make Sense of Any Mess*. +- Dan Brown, *Communicating Design*. diff --git a/.agents/skills/interface-design/references/08-interaction-design.md b/.agents/skills/interface-design/references/08-interaction-design.md new file mode 100644 index 000000000..db7c2ca80 --- /dev/null +++ b/.agents/skills/interface-design/references/08-interaction-design.md @@ -0,0 +1,168 @@ +# Interaction design + +## Contents + +- Interaction as behavior +- Discoverability and signifiers +- Feedback and system status +- Control, agency, and reversibility +- Modes, selection, and direct manipulation +- Errors and validation +- Frequency and expertise +- Exercises and rubric +- Sources + +## Interaction as behavior + +Interaction design shapes the conversation among person, interface, system, and other actors +over time. A static screen is one state in that conversation. + +For each interaction specify: + +- trigger and available inputs; +- eligibility and permissions; +- immediate acknowledgment; +- progress or latency behavior; +- result and changed state; +- persistence; +- cancellation and undo; +- errors and recovery; +- accessibility semantics and keyboard operation. + +## Discoverability and signifiers + +An action is discoverable when people can perceive that it exists and predict its likely effect. + +- Use familiar control forms for common behavior. +- Give unfamiliar behavior explicit labels, instruction, demonstration, or safe exploration. +- Preserve a visible distinction between interactive and static content. +- Do not rely on hover to reveal essential actions. +- Icons require learned meaning; pair ambiguous icons with labels. +- Gesture shortcuts should supplement visible paths, not replace them. + +Consistency supports learning, but locally consistent misuse of a platform convention can still +be surprising. + +## Feedback and system status + +Feedback should be: + +- immediate enough to connect with the action; +- proportional to consequence; +- located near cause and effect; +- persistent long enough to perceive; +- expressed through more than one sensory channel when necessary; +- honest about progress and uncertainty. + +Differentiate acknowledgment ("input received"), progress ("work continues"), completion +("result exists"), and outcome ("what changed"). A spinner acknowledges activity but does not +explain state or recovery. + +For long work, support background continuation, cancellation, and safe navigation when possible. +Never display fake precise progress. + +## Control, agency, and reversibility + +Good interaction preserves agency: + +- preview consequential effects; +- let people cancel or back out; +- use undo for reversible operations; +- keep drafts and autosave visible and trustworthy; +- explain permissions before requesting them; +- avoid changing user data silently; +- expose automated actions and their scope. + +Use confirmation when the action is rare, consequential, and difficult to reverse. The +confirmation must name the object and effect. Generic "Are you sure?" dialogs teach habituation. + +## Modes, selection, and direct manipulation + +Modes change the meaning of input. Make active modes visible, bounded, and easy to exit. +Prefer modeless behavior when it does not create ambiguity. + +Selection is state, not merely highlight: + +- distinguish focus, hover, active press, current item, and selected items; +- show selection count and action scope; +- preserve selection through operations only when expected; +- define partial and mixed selection; +- make bulk actions reversible where possible. + +Direct manipulation links action, object, and feedback. It can improve understanding, but +precision, accessibility, and discoverability require alternate controls for drag, resize, +reorder, and spatial gestures. + +## Errors and validation + +Prevent errors through constraints, examples, safe defaults, and clear dependencies. + +Validation should: + +- occur early enough to help but not interrupt every keystroke; +- preserve input; +- identify the specific field or object; +- explain what happened in plain language; +- describe how to fix it; +- move and announce focus appropriately; +- provide a summary when errors are distributed; +- avoid blaming the user. + +Distinguish invalid format, unavailable value, permission denial, conflict, network failure, +expired state, and system error. Each requires different recovery. + +## Frequency and expertise + +Optimize differently by frequency: + +- repeated expert actions: stable layout, keyboard access, low latency, batch operations; +- occasional actions: clear labels and guided context; +- first-run actions: examples and safe exploration; +- rare high-risk actions: explicit consequences and recovery. + +Do not animate or confirm high-frequency actions by default. Small delays compound into a slow +product. + +## Exercises + +### State inventory + +Choose one button that starts an asynchronous operation. Draw every state and transition, +including duplicate activation, cancellation, timeout, partial success, and lost permission. + +### Undo versus confirmation + +Redesign five destructive operations. Decide which need undo, confirmation, delayed commit, +version history, or a combination. Defend by consequence and reversibility. + +### Keyboard translation + +Take a drag-and-drop interaction and create equivalent keyboard and assistive-technology paths +without reducing functionality. + +### Feedback timing + +Prototype the same action with immediate local acknowledgment, delayed global feedback, and +optimistic update. Evaluate trust under success and failure. + +## Critique rubric + +- Can people discover actions and predict effects? +- Is feedback timely, local, and honest? +- Are long operations cancellable or safely backgrounded? +- Are destructive effects named and recoverable? +- Are modes and selections unambiguous? +- Does validation preserve data and guide repair? +- Are frequent and expert paths efficient? +- Are alternate inputs equivalent? + +## Sources and further study + +- [CMU Interaction Design Fundamentals](https://metals.hcii.cmu.edu/curriculum/) +- [Apple design principles](https://developer.apple.com/design/human-interface-guidelines/design-principles) +- [W3C ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) +- [Nielsen Norman Group: Ten usability heuristics](https://www.nngroup.com/articles/ten-usability-heuristics/) +- Don Norman, *The Design of Everyday Things*. +- Alan Cooper et al., *About Face: The Essentials of Interaction Design*. +- Bill Moggridge, *Designing Interactions*. +- Dan Saffer, *Designing for Interaction*. diff --git a/.agents/skills/interface-design/references/09-components-forms-and-states.md b/.agents/skills/interface-design/references/09-components-forms-and-states.md new file mode 100644 index 000000000..424231f97 --- /dev/null +++ b/.agents/skills/interface-design/references/09-components-forms-and-states.md @@ -0,0 +1,203 @@ +# Components, forms, and interface states + +## Contents + +- Components as contracts +- Anatomy and behavior +- State matrices +- Forms +- Tables and repeated objects +- Empty, loading, and error states +- Exercises and rubric +- Sources + +## Components as contracts + +A component is a reusable agreement among meaning, anatomy, behavior, content, accessibility, +and implementation. Visual similarity alone does not justify one component; shared semantics +and behavior do. + +Before creating a component, ask: + +- Is the pattern repeated? +- Does it carry stable meaning? +- Do instances require the same behavior and accessibility contract? +- Would central governance reduce risk? +- Are differences variants or actually different concepts? + +Avoid abstracting a one-off composition prematurely. Avoid creating a "universal card" whose +variants encode unrelated products. + +## Anatomy and behavior + +Document: + +- purpose and when not to use; +- required and optional anatomy; +- content constraints; +- variants and sizes; +- interaction and keyboard model; +- focus order and accessible name; +- responsive behavior; +- localization; +- state and transition model; +- composition rules; +- examples and anti-examples. + +Visible anatomy and semantic anatomy are not identical. An icon button still requires an +accessible name; a visually grouped field still needs programmatic relationships. + +## State matrices + +Do not stop at default, hover, and disabled. Consider: + +```text +availability: enabled | disabled | read-only | permission-limited +pointer: rest | hover | pressed | dragging +keyboard: unfocused | focused | focus-visible +selection: unselected | selected | mixed | current +validation: neutral | warning | invalid | valid +async: idle | pending | progress | success | failure | cancelled +data: empty | partial | stale | offline | conflict +``` + +Only relevant dimensions should appear in a component, but the product must resolve their +combinations. Define precedence: how does selected + focused + invalid appear without ambiguity? + +Disabled controls are often poorly explained. If an action is unavailable, consider leaving it +enabled and explaining the failed precondition, or pair disabled state with a discoverable +reason. Do not rely on tooltips for touch or keyboard-only access. + +## Forms + +### Structure + +- Ask only for information required by the current outcome. +- Group by user meaning, not database table. +- Follow a plausible completion order. +- Use one column for most data entry; use multiple columns only for tightly related short fields. +- Keep persistent labels. +- Show format examples and constraints before failure. +- Mark optionality consistently. +- Preserve values through error and navigation. + +### Control selection + +- Checkbox: independent binary choices or opt-in. +- Radio group: one choice from a small visible set. +- Select/listbox: constrained choice where displaying all options is costly. +- Combobox: selection with search or free entry, with a defined keyboard model. +- Switch: immediate on/off setting, not submission of a form. +- Button: action. +- Link: navigation. + +Choose native controls when they meet the need. Custom controls inherit responsibility for +keyboard, focus, semantics, zoom, high contrast, and assistive technology. + +### Validation + +Associate errors with fields programmatically and visually. Move focus to an error summary only +when it helps navigation. Avoid clearing the field or reporting a generic failure. + +### High-consequence forms + +For payment, health, legal, identity, and destructive operations: + +- reveal consequences and totals before commitment; +- distinguish review from submit; +- support correction; +- prevent duplicate submission; +- show durable confirmation and reference; +- communicate privacy and retention where relevant. + +## Tables and repeated objects + +Use a table when the primary task is comparison across consistent attributes. Use a list when +each object's narrative or actions dominate. + +Design: + +- row identity and selection; +- sort state; +- filters and active constraints; +- column priority and resize behavior; +- numeric alignment; +- truncation and full-value access; +- bulk actions; +- editing and validation; +- sticky regions; +- loading, empty, partial, and stale data; +- keyboard model. + +An ARIA `grid` is a composite interactive widget with managed focus, not a visual synonym for a +table. Consult W3C APG before choosing it. + +## Empty, loading, and error states + +### Empty + +Differentiate: + +- first use; +- no results after filtering; +- no permission; +- data not yet synchronized; +- user intentionally cleared content; +- feature unavailable. + +Explain why it is empty and give the next relevant action. Avoid decorative illustration that +pushes recovery below the fold. + +### Loading + +Preserve layout to reduce movement. Use skeletons only when shape is predictable; otherwise show +honest progress or a stable placeholder. Keep prior data visible and label it stale when that is +safer than blanking the interface. + +### Error + +State what failed, impact, retained data, recovery, and support path. Avoid error codes as the +only explanation, but retain diagnostic details when useful. + +## Exercises + +### Component contract + +Choose a seemingly simple control and document purpose, anatomy, content, states, keyboard, +responsive behavior, localization, and non-use cases. + +### State collision + +Render all meaningful combinations for selected, focus-visible, invalid, loading, and disabled. +Resolve visual conflicts without adding arbitrary decoration. + +### Form reduction + +Take a 20-field form. For every field identify decision use, legal need, inference possibility, +timing, and consequence of omission. Redesign the sequence. + +### Table task test + +List the five most common table tasks. Reprioritize columns, controls, and density around those +tasks rather than showing every available field. + +## Critique rubric + +- Does each component have stable semantics and behavior? +- Are state combinations and precedence defined? +- Do forms follow user meaning and minimize unnecessary entry? +- Are native semantics preserved or responsibly recreated? +- Are table structure and interaction matched to comparison tasks? +- Are loading, empty, error, stale, partial, and permission states distinct? +- Is recovery visible and data-preserving? + +## Sources and further study + +- [W3C ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) +- [W3C APG grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) +- [GOV.UK Design System components](https://design-system.service.gov.uk/components/) +- [GOV.UK Design System patterns](https://design-system.service.gov.uk/patterns/) +- [IBM Carbon form accessibility](https://carbondesignsystem.com/components/form/accessibility/) +- [Baymard checkout usability research](https://baymard.com/research/checkout-usability) +- Caroline Jarrett and Gerry Gaffney, *Forms That Work*. +- Adam Silver, *Form Design Patterns*. diff --git a/.agents/skills/interface-design/references/10-design-systems.md b/.agents/skills/interface-design/references/10-design-systems.md new file mode 100644 index 000000000..4d77f8f10 --- /dev/null +++ b/.agents/skills/interface-design/references/10-design-systems.md @@ -0,0 +1,193 @@ +# Design systems + +## Contents + +- System purpose +- Foundations and tokens +- Components and patterns +- Architecture and naming +- Governance +- Documentation and quality +- Adoption and measurement +- Exercises and rubric +- Sources + +## System purpose + +A design system is a governed set of shared decisions that helps teams produce coherent, +accessible experiences. It is not merely a component library, token file, or visual style guide. + +Create a system to solve repeated coordination and quality problems. Do not systematize +differences that are meaningful to distinct products, audiences, or platforms. + +Define: + +- products and platforms in scope; +- principles and quality bar; +- ownership and decision rights; +- contribution and release model; +- relationship among design, code, content, and accessibility; +- migration and deprecation strategy. + +## Foundations and tokens + +Foundations include color, type, spacing, layout, shape, elevation, motion, iconography, and +content conventions. + +Use token layers: + +1. **Primitive**: raw palette or scale values, such as `color.blue.600`. +2. **Semantic**: purpose, such as `color.text.link`. +3. **Component**: intentional local mapping, such as `button.primary.background.rest`. + +Do not create a component token for every CSS property. Tokens should encode decisions that need +coordination or theming. + +### Token requirements + +- type and unit; +- semantic description; +- supported themes and platforms; +- fallback; +- contrast or accessibility relationship; +- lifecycle status; +- source of truth and export path. + +Theme mappings preserve meaning, not numeric identity. Platform mappings may legitimately use +different dimensions or behaviors. + +## Components and patterns + +- A **component** is a reusable UI building block with a semantic and behavioral contract. +- A **pattern** combines components and content to solve a recurring user problem. +- A **template** provides layout structure without determining all content. + +Document purpose, anatomy, states, behavior, accessibility, content, localization, responsive +rules, variants, non-use cases, and implementation status. + +Avoid: + +- one component per visual mockup; +- configuration APIs so broad that no outcome remains coherent; +- variants that encode whole product features; +- visual parity without behavioral parity; +- declaring accessibility "inherited" without testing composition. + +## Architecture and naming + +Names should express meaning at the right level: + +- prefer `feedback.critical` over `red`; +- prefer `space.inline.related` only if the semantic abstraction is actually maintained; +- avoid clever taxonomies the team cannot recall; +- preserve platform-native terminology when interoperability matters. + +The system needs explicit extension points. Product teams should be able to compose local +patterns without forking foundations or smuggling one-off values into shared components. + +## Governance + +Define: + +- maintainer responsibilities; +- request and proposal format; +- design and engineering review; +- accessibility acceptance criteria; +- research evidence expected; +- status: experimental, stable, deprecated; +- versioning and migration; +- decision record; +- release communication. + +Contribution is a product workflow. If submitting an improvement is harder than bypassing the +system, teams will create local alternatives. + +Use federated contribution when domain teams possess specialized knowledge, with central +stewardship for coherence and risk. + +## Documentation and quality + +Documentation must answer: + +- Why and when should this be used? +- When should it not be used? +- What behavior is guaranteed? +- Which content fits? +- How does it adapt? +- What has been accessibility-tested, and how? +- What remains the consuming team's responsibility? + +Include realistic examples, edge cases, and paired design/code status. A pristine component +gallery without production content is not sufficient. + +Quality gates may include: + +- token conformance; +- visual regression; +- keyboard and screen-reader testing; +- zoom and high-contrast testing; +- localization and bidirectionality; +- state matrix coverage; +- API and content review; +- usability evidence for patterns. + +## Adoption and measurement + +Do not measure only component usage. Track: + +- time to produce and change compliant interfaces; +- accessibility defects; +- duplicated local patterns; +- upgrade lag; +- contribution throughput; +- documentation success; +- user-experience consistency where consistency is beneficial; +- exceptions and why they exist. + +High adoption can coexist with poor user outcomes. The system supports product design; it does +not replace it. + +## Exercises + +### Pattern inventory + +Audit five product areas. Cluster repeated semantics and behavior, not visual resemblance. +Identify candidates for shared components, patterns, and intentional local variation. + +### Token refactor + +Convert a raw-value theme into primitive, semantic, and component layers. Demonstrate a dark +theme and high-contrast mapping without renaming roles. + +### Contribution simulation + +Write a proposal for a new pattern including user problem, evidence, alternatives, accessibility, +API, content, migration, and ownership. Review it as three different product teams. + +### Deprecation plan + +Replace a widely used but flawed component without breaking product delivery. Include migration, +communications, metrics, and end-of-life criteria. + +## Critique rubric + +- What coordination problem does the system solve? +- Are tokens semantic, limited, and themeable? +- Do components encode stable meaning and behavior? +- Are patterns backed by real recurring user problems? +- Can product teams extend without forking? +- Are contribution, versioning, and deprecation practical? +- Is accessibility verified at component and composition levels? +- Are adoption metrics connected to quality and user outcomes? + +## Sources and further study + +- [IBM Carbon Design System](https://carbondesignsystem.com/) +- [Material Design 3](https://m3.material.io/) +- [Microsoft Fluent 2](https://fluent2.microsoft.design/) +- [GOV.UK Design System](https://design-system.service.gov.uk/) +- [US Web Design System](https://designsystem.digital.gov/) +- [W3C Design Tokens Community Group](https://www.w3.org/community/design-tokens/) +- Alla Kholmatova, *Design Systems*. +- Nathan Curtis, *Modular Web Design*. +- Yesenia Perez-Cruz, *Expressive Design Systems*. diff --git a/.agents/skills/interface-design/references/11-accessibility-and-inclusive-design.md b/.agents/skills/interface-design/references/11-accessibility-and-inclusive-design.md new file mode 100644 index 000000000..15258d100 --- /dev/null +++ b/.agents/skills/interface-design/references/11-accessibility-and-inclusive-design.md @@ -0,0 +1,189 @@ +# Accessibility and inclusive design + +## Contents + +- Accessibility as design quality +- Standards model +- Perception +- Operation +- Understanding +- Robustness +- Cognitive and situational inclusion +- Design and testing workflow +- Exercises and rubric +- Sources + +## Accessibility as design quality + +Accessibility is a continuous design, engineering, content, and testing responsibility. Using an +accessible component library does not make the assembled product accessible. GOV.UK and IBM +explicitly emphasize this distinction. + +Design for disability, temporary impairment, situational constraints, aging, language, +technology variation, and different input/output modes. Avoid framing disabled people only as +edge cases or accessibility as a compliance pass. + +## Standards model + +Use WCAG 2.2 as the primary web conformance reference. Its four principles are: + +- **Perceivable** +- **Operable** +- **Understandable** +- **Robust** + +For native platforms, combine applicable regulation and standards with platform accessibility +guidance. Use WAI-ARIA only when native HTML semantics cannot express the required widget. + +Distinguish: + +- normative success criterion; +- informative understanding document; +- sufficient or advisory technique; +- APG pattern; +- platform recommendation; +- organizational policy. + +Do not promise legal compliance solely from an interface review. + +## Perception + +- Provide text alternatives for meaningful non-text content. +- Preserve semantic heading and landmark structure. +- Ensure required text and graphics meet applicable contrast requirements. +- Do not encode meaning by color, sound, position, or shape alone. +- Support zoom, text resizing, reflow, and increased contrast. +- Avoid text embedded in images when real text is possible. +- Caption meaningful audio and describe relevant visual information. +- Preserve information under high contrast and forced colors. + +Design annotations should identify reading order, accessible names, descriptions, status +announcements, and nonvisual relationships. + +## Operation + +- Make every function available by keyboard or an equivalent accessible input. +- Keep focus visible and unobscured. +- Use a logical focus order that follows task and reading structure. +- Avoid keyboard traps. +- Give controls sufficient target size and spacing. +- Provide alternatives to dragging and complex gestures. +- Allow enough time and support extension or saving where possible. +- Avoid flashing and motion that can trigger seizures or vestibular symptoms. +- Provide skip paths and efficient navigation for repeated regions. + +Focus, selected, active, checked, current, and hover are different states. Do not merge their +visual or semantic meaning. + +## Understanding + +- Use clear labels and instructions. +- Keep repeated navigation and help in consistent locations. +- Identify errors specifically and explain repair. +- Avoid redundant re-entry of information already supplied when WCAG criteria apply. +- Support password managers and accessible authentication. +- Make consequences visible before commitment. +- Use plain language without removing necessary domain precision. +- Preserve context across interruptions and errors. + +## Robustness + +- Prefer semantic HTML and native controls. +- Supply accessible names, roles, values, and state. +- Test dynamic updates with assistive technology. +- Follow documented keyboard conventions for composite widgets. +- Avoid invalid or contradictory ARIA. +- Do not use ARIA to repair an element whose native semantic behavior is available. + +W3C APG provides patterns and examples, but it explicitly states that it is not a normative +standard or a production design system. + +## Cognitive and situational inclusion + +W3C cognitive guidance goes beyond minimum conformance: + +- make purpose and next steps clear; +- use familiar hierarchy and controls; +- keep instructions available; +- reduce unnecessary memory; +- prevent and help correct mistakes; +- support interruption and return; +- avoid time pressure; +- allow simplification without hiding consequences. + +Inclusive design asks who may be excluded by assumptions about vision, hearing, movement, +speech, cognition, literacy, culture, connectivity, device, or environment. + +Do not use personas that turn disability into a checklist. Include disabled people in research +and testing, compensate expertise, and preserve disagreement. + +## Design and testing workflow + +At concept: + +- include accessibility in constraints and recruitment; +- choose semantic patterns; +- map reading and focus order; +- design text scaling and alternate input. + +At component: + +- specify states, names, roles, keyboard behavior, announcements, and contrast; +- inspect combinations and themes. + +At integrated product: + +- test keyboard, screen reader, zoom/reflow, high contrast, reduced motion, and realistic tasks; +- include manual testing; automated checks find only a subset of problems. + +At release: + +- record known limitations; +- provide an accessible support channel; +- treat defects by user consequence, not only criterion count. + +## Exercises + +### Nonvisual specification + +Describe a complex screen's structure, focus order, accessible names, dynamic announcements, and +error recovery without referring to coordinates or color. + +### Constraint rotation + +Complete a key flow using keyboard only, screen magnification, screen reader, reduced motion, +and voice control. Do not simulate lived experience; use the exercise to find technical barriers. + +### Accessible component annotation + +Annotate one combobox or grid with semantics, keyboard interaction, focus management, state, and +fallback. Compare against W3C APG. + +### Exclusion audit + +List every assumed ability, device, environment, language, and level of expertise in a design. +Prioritize exclusions by consequence and frequency evidence. + +## Critique rubric + +- Which WCAG criteria and platform requirements apply? +- Is information perceivable without a single sensory channel? +- Is every function operable across relevant input methods? +- Are focus, reading order, and dynamic announcements defined? +- Does content help prevent and recover from error? +- Does the design survive zoom, reflow, text scaling, themes, and localization? +- Were disabled people included in research and testing? +- Are conformance, usability, and inclusion reported separately? + +## Sources and further study + +- [W3C WCAG overview and current publications](https://www.w3.org/WAI/standards-guidelines/wcag/) +- [W3C WCAG 2.2 new criteria](https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/) +- [W3C ARIA Authoring Practices Guide introduction](https://www.w3.org/WAI/ARIA/apg/about/introduction/) +- [W3C cognitive accessibility](https://www.w3.org/WAI/cognitive/) +- [Microsoft Inclusive Design](https://inclusive.microsoft.design/) +- [Apple HIG: Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility) +- [IBM Carbon: Accessibility](https://carbondesignsystem.com/guidelines/accessibility/overview/) +- [GOV.UK Design System accessibility strategy](https://design-system.service.gov.uk/accessibility/accessibility-strategy/) +- Kat Holmes, *Mismatch: How Inclusion Shapes Design*. +- Regine Gilbert, *Inclusive Design for a Digital World*. diff --git a/.agents/skills/interface-design/references/12-motion.md b/.agents/skills/interface-design/references/12-motion.md new file mode 100644 index 000000000..d4c93a92a --- /dev/null +++ b/.agents/skills/interface-design/references/12-motion.md @@ -0,0 +1,147 @@ +# Motion and temporal design + +## Contents + +- Motion as information +- Decision framework +- Spatial and state continuity +- Timing and easing +- Interruptibility and performance +- Accessibility +- Exercises and rubric +- Sources + +## Motion as information + +Motion is a change in state over time. Use it to: + +- acknowledge input; +- preserve object continuity; +- explain spatial or causal relationships; +- indicate progress or system status; +- direct attention to meaningful change; +- communicate hierarchy and entry/exit; +- add character when it does not tax the task. + +Do not add motion simply because implementation makes it easy. Apple recommends purposeful, +brief, precise feedback and warns against frequent gratuitous animation. + +## Decision framework + +Before designing an animation, answer: + +1. What change must the person understand? +2. Would an immediate state change already communicate it? +3. How frequently will this occur? +4. Is the action direct, navigational, ambient, or explanatory? +5. Can the motion be interrupted or reversed? +6. What is the reduced-motion equivalent? +7. What happens under latency, dropped frames, and rapid repeated input? + +Frequent expert operations should usually be instant or extremely restrained. Rare onboarding or +celebratory moments can carry more expression if they remain skippable and appropriate. + +## Spatial and state continuity + +Maintain origin and destination: + +- anchored layers should appear related to their trigger; +- objects that move should remain identifiable; +- insertion and deletion should explain list reflow; +- navigation transitions should preserve orientation; +- dismissal should follow the established spatial model. + +Use shared-element or container transformations only when the objects are meaningfully +continuous. Morphing unrelated surfaces can create a false model. + +Motion is one channel. Reinforce important state through layout, text, color, or sound so the +meaning survives reduced motion. + +## Timing and easing + +Timing depends on distance, scale, frequency, consequence, platform, and input. + +- Immediate acknowledgment must feel coupled to input. +- Small frequent transitions should be brief. +- Large spatial transitions may need more time to remain trackable. +- Exit can often be faster than entry. +- Stagger only when sequence supports hierarchy; excessive staggering delays access. + +Easing expresses physical and attentional behavior: + +- entering and feedback transitions often begin promptly and decelerate; +- on-screen repositioning often benefits from acceleration and deceleration; +- continuous progress uses linear time when constant rate is meaningful; +- spring motion suits interruptible, physically modeled interaction but must avoid gratuitous + bounce. + +Do not standardize one duration or curve for every component. Create a small semantic motion +system and test the perceived result. + +## Interruptibility and performance + +- Let input interrupt or reverse transitions. +- Do not block interaction until decorative motion finishes. +- Avoid queuing stale animations during rapid input. +- Preserve state if animation is skipped. +- Prefer properties and techniques that meet performance budgets on target devices. +- Test under CPU load, power-saving modes, and low-end hardware. +- Avoid layout movement that causes accidental activation or reading loss. + +Skeleton screens are not motion decoration. Use them only when they represent a predictable +structure and do not create false progress. + +## Accessibility + +- Respect reduced-motion preferences. +- Replace large translation, zoom, parallax, and depth changes with fades or immediate changes. +- Avoid flashing beyond applicable thresholds. +- Avoid sustained peripheral and oscillating motion. +- Do not make animation the only way to perceive status. +- Let people pause, stop, or hide nonessential moving content where standards require. + +W3C technique C39 documents `prefers-reduced-motion` as one sufficient technique for +interaction-triggered animation. The design must still define a meaningful alternative. + +## Exercises + +### No-motion baseline + +Design a complete interaction with no animation. Confirm that state, hierarchy, and feedback +remain understandable. Add only motion that improves a named relationship. + +### Frequency ladder + +Apply the same transition to actions performed once, daily, and hundreds of times. Adjust or +remove motion based on cumulative interaction cost. + +### Reduced-motion pair + +Create full-motion and reduced-motion versions. Verify identical task meaning, control, and +completion. + +### Interruption test + +Trigger, reverse, and repeat every transition before it completes. Repair stale state, queues, +and visual discontinuities. + +## Critique rubric + +- Does each motion have an informational or experiential purpose? +- Does it preserve cause, origin, destination, and object continuity? +- Is timing proportional to frequency and scale? +- Can interaction interrupt or skip it? +- Does meaning survive without motion? +- Is reduced-motion behavior explicitly designed? +- Does it perform under realistic load and devices? +- Is it subordinate to the user's task? + +## Sources and further study + +- [Apple Human Interface Guidelines: Motion](https://developer.apple.com/design/human-interface-guidelines/motion) +- [Material Design 3: Motion](https://m3.material.io/styles/motion/overview) +- [W3C Technique C39: prefers-reduced-motion](https://www.w3.org/WAI/WCAG22/Techniques/css/C39) +- [W3C WCAG 2.2: Animation from interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) +- [Microsoft Fluent 2: Motion](https://fluent2.microsoft.design/motion) +- Val Head, *Designing Interface Animation*. +- Rachel Nabors, *Animation at Work*. diff --git a/.agents/skills/interface-design/references/13-content-design.md b/.agents/skills/interface-design/references/13-content-design.md new file mode 100644 index 000000000..1cbaa8cc2 --- /dev/null +++ b/.agents/skills/interface-design/references/13-content-design.md @@ -0,0 +1,150 @@ +# Content design and interface language + +## Contents + +- Content as interface +- Voice, tone, and terminology +- Labels and actions +- Instructions and disclosure +- Errors, empty states, and confirmations +- Localization and accessibility +- Exercises and rubric +- Sources + +## Content as interface + +Words define concepts, choices, state, consequence, and trust. Content design begins with user +needs and task structure, not polishing copy after the interface is complete. + +For each text element know: + +- audience and context; +- job it performs; +- source of truth; +- required precision; +- lifecycle and ownership; +- localization and accessibility implications. + +Remove text only when meaning remains apparent. Minimal wording that forces guessing is not +simplicity. + +## Voice, tone, and terminology + +**Voice** is the stable character of an organization or product. **Tone** adapts to context. +A payment failure, celebration, legal consent, and empty canvas should not sound identical. + +- Prefer familiar, concrete language. +- Use domain terminology when users know it and precision matters. +- Define unavoidable specialist terms. +- Maintain a terminology system for entities, actions, and statuses. +- Avoid switching synonyms for variety in operational UI. +- Do not anthropomorphize systems in ways that conceal responsibility or capability. + +IBM Carbon's content guidance treats clarity, logic, research, and accessible writing as part of +product experience. Its sentence-case convention is a system choice supported by readability and +consistency, not an aesthetic law for every language. + +## Labels and actions + +- Name controls by the outcome people expect. +- Distinguish navigation from action. +- Use specific verbs: "Archive order" is clearer than "Confirm." +- Name destructive actions and objects. +- Keep paired actions unambiguous: "Discard draft" and "Continue editing." +- Avoid labels whose meaning exists only in nearby explanatory prose. +- Give icon-only controls accessible names and visible labels when recognition is uncertain. + +Headings should describe the section's content or decision. Decorative eyebrow labels and +generic headings such as "Overview" consume hierarchy without adding scent. + +## Instructions and disclosure + +- Put necessary instruction before the action or field. +- Reveal examples in the context where they help. +- Separate requirement from optional advice. +- Disclose cost, consequence, data use, and irreversible effects before commitment. +- Keep help consistent and retrievable. +- Prefer progressive disclosure for secondary detail, not informed consent. + +Placeholders disappear and are not a substitute for labels or enduring format guidance. + +## Errors, empty states, and confirmations + +### Error formula + +1. What happened? +2. What is affected? +3. What was preserved? +4. What can the person do next? +5. Where can they get help if recovery fails? + +Avoid "Oops," humor, blame, and apology without repair. Preserve diagnostic codes behind a +details path if support needs them. + +### Empty states + +State the reason: first use, no matching results, permission, synchronization, or true absence. +Offer the relevant next action without inventing a marketing moment. + +### Confirmation and success + +Confirm the completed outcome and important downstream effects. Provide a durable reference, +receipt, or undo where needed. Do not use a generic toast for high-consequence work that needs +later retrieval. + +## Localization and accessibility + +- Write source text that can be translated without ambiguity. +- Avoid concatenating sentence fragments. +- Support plural, gender, grammatical case, and variable word order. +- Expect text expansion and different scripts. +- Localize dates, time, numbers, currencies, addresses, and names. +- Do not encode direction with "left" and "right" when layout can be bidirectional. +- Use plain language while preserving necessary legal or technical precision. +- Make link text meaningful out of context. +- Ensure status text is available to assistive technology. + +## Exercises + +### Verb audit + +List every button and link in a flow. Replace vague labels with outcomes, then verify that +neighboring actions remain distinct. + +### Error recovery set + +Write messages for format error, conflict, permission denial, offline state, partial failure, +and system outage. Give each a different truthful recovery path. + +### Terminology map + +Inventory nouns and verbs across a product. Resolve synonyms and collisions, recording the user +or domain evidence behind the chosen terms. + +### Translation stress + +Translate representative UI strings into structurally different languages. Repair layout and +source-copy assumptions without shortening meaning. + +## Critique rubric + +- Does every text element perform a clear job? +- Are entities and actions named consistently? +- Do labels predict outcome and consequence? +- Is critical instruction placed before the decision? +- Do errors explain impact and recovery? +- Are empty and success states truthful and durable? +- Can the content localize without structural breakage? +- Is tone appropriate to consequence and trust? + +## Sources and further study + +- [GOV.UK Content Design guidance](https://www.gov.uk/guidance/content-design) +- [GOV.UK Style Guide](https://www.gov.uk/guidance/style-guide) +- [IBM Carbon content overview](https://carbondesignsystem.com/guidelines/content/overview/) +- [IBM Carbon writing style](https://carbondesignsystem.com/guidelines/content/writing-style/) +- [Apple HIG: Writing](https://developer.apple.com/design/human-interface-guidelines/writing) +- [W3C cognitive accessibility guidance](https://www.w3.org/WAI/WCAG2/supplemental/) +- Sarah Richards, *Content Design*. +- Torrey Podmajersky, *Strategic Writing for UX*. +- Kinneret Yifrah, *Microcopy: The Complete Guide*. diff --git a/.agents/skills/interface-design/references/14-data-dense-and-commerce-interfaces.md b/.agents/skills/interface-design/references/14-data-dense-and-commerce-interfaces.md new file mode 100644 index 000000000..61d5262e4 --- /dev/null +++ b/.agents/skills/interface-design/references/14-data-dense-and-commerce-interfaces.md @@ -0,0 +1,180 @@ +# Data-dense, dashboard, and commerce interfaces + +## Contents + +- Domain evidence +- Dashboards and operational interfaces +- Tables and comparison +- Data visualization +- Ecommerce discovery +- Cart, checkout, and post-purchase +- Exercises and rubric +- Sources + +## Domain evidence + +Generic design heuristics are insufficient for specialized interfaces. Use domain research, +observe real work, and identify consequence. + +Baymard provides large-scale ecommerce usability evidence based on moderated studies, +benchmarking, eye tracking, and quantitative research. Its findings apply most directly to +consumer ecommerce contexts and must be checked against market, product, and fulfillment. + +For enterprise tools, study actual roles, frequency, terminology, handoffs, exceptions, +permissions, and cost of error. A visually calm dashboard may still fail experts if it hides +density or slows comparison. + +## Dashboards and operational interfaces + +Start with decisions: + +- What decision or action does each region support? +- What timeframe and comparison baseline matter? +- Is data real-time, delayed, estimated, sampled, or stale? +- Which anomalies require attention? +- What action follows an observation? +- Who is accountable? + +Avoid KPI-card collections without a decision model. Give metrics: + +- precise name and definition; +- value and unit; +- timeframe; +- comparison; +- status or threshold; +- data freshness; +- drill-down or action; +- uncertainty where applicable. + +Use overview, zoom/filter, and details-on-demand as a useful sequence, but preserve essential +context and avoid making every answer require a drill-down. + +## Tables and comparison + +- Prioritize columns by task, not data-model order. +- Keep row identity stable. +- Align numbers and units. +- Separate primary value from supporting metadata. +- Make sort and filters visible, reversible, and restorable. +- Support bulk selection with explicit scope. +- Preserve column and density preferences for frequent expert work. +- Indicate stale, partial, masked, missing, and unavailable data distinctly. +- Avoid horizontal scroll for simple lists, but use it when preserving true multidimensional + comparison is better than destroying the table. + +Use virtualization and sticky regions carefully; they must not break keyboard, reading order, +find-in-page, or assistive technology. + +## Data visualization + +Choose chart by relationship and task: + +- comparison; +- change over time; +- distribution; +- part-to-whole; +- correlation; +- spatial relationship; +- network; +- flow. + +Principles: + +- position on a shared scale generally supports precise comparison better than area, angle, or + color intensity; +- label directly where practical; +- show units, source, timeframe, and uncertainty; +- avoid decorative 3D that distorts quantity; +- use zero baselines when required for honest bar-length comparison; +- preserve meaningful nonzero baselines for some line charts with clear disclosure; +- annotate events and thresholds; +- provide accessible text or tabular alternatives; +- do not use animation to hide a changing scale. + +Visualization is an argument about data. Make transformations and omissions inspectable. + +## Ecommerce discovery + +Support different shopping modes: + +- known-item search; +- category exploration; +- compatibility or specification filtering; +- inspiration; +- replenishment; +- comparison. + +Design search, categories, filters, product cards, product detail, availability, pricing, +variants, delivery, returns, reviews, and trust as one information system. + +- Keep active filters visible and removable. +- Distinguish unavailable combinations from sold-out variants. +- Show total price and recurring obligations clearly. +- Put compatibility and critical constraints near selection. +- Avoid hiding delivery and return information until checkout. +- Preserve product context when returning from detail. + +## Cart, checkout, and post-purchase + +Baymard's research repeatedly finds avoidable friction in account creation, address forms, +payment, validation, shipping, order review, and recovery. + +Transferable principles: + +- permit guest checkout where the business allows; +- ask only necessary fields; +- use browser autofill and appropriate input semantics; +- keep labels persistent; +- update totals and shipping changes near their cause; +- preserve entered data through error; +- make final commitment and total explicit; +- prevent duplicate orders; +- provide confirmation, reference, receipt, fulfillment status, and correction/support paths. + +Conversion is not the only outcome. Optimize informed choice, trust, accessibility, fulfillment, +returns, support burden, and long-term relationship. + +## Exercises + +### Decision-first dashboard + +Interview a role or use supplied research. List five recurring decisions, then design the +smallest dashboard that supports them. Reject metrics without a decision or action. + +### Chart remapping + +Represent one dataset with five chart types. Identify which questions each makes easy, hard, or +misleading. + +### Table under pressure + +Design a table for 10, 1,000, and 1,000,000 records; narrow screen; keyboard use; masked fields; +partial loading; bulk action; and conflicting edits. + +### Checkout failure map + +Map cart through confirmation with authentication failure, price update, address rejection, +payment decline, network timeout, duplicate submission, and return after interruption. + +## Critique rubric + +- Is every metric connected to a decision or action? +- Are data definition, timeframe, freshness, and uncertainty visible? +- Does the representation match the comparison task? +- Are dense interfaces optimized for frequency and expertise? +- Are filters, sort, selection, and state restorable? +- Does ecommerce expose price, availability, delivery, and returns before commitment? +- Does checkout minimize entry, preserve data, and support recovery? +- Are accessibility and nonvisual alternatives designed? + +## Sources and further study + +- [Baymard checkout usability research](https://baymard.com/research/checkout-usability) +- [Baymard ecommerce search research](https://baymard.com/research/ecommerce-search) +- [W3C APG grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) +- [IBM Carbon data table](https://carbondesignsystem.com/components/data-table/usage/) +- [UC San Diego Information Visualization course](https://hci.ucsd.edu/220/) +- Edward Tufte, *The Visual Display of Quantitative Information*. +- Tamara Munzner, *Visualization Analysis and Design*. +- Stephen Few, *Information Dashboard Design*. +- Isabel Meirelles, *Design for Information*. diff --git a/.agents/skills/interface-design/references/15-critique-and-studio-practice.md b/.agents/skills/interface-design/references/15-critique-and-studio-practice.md new file mode 100644 index 000000000..9006ce87b --- /dev/null +++ b/.agents/skills/interface-design/references/15-critique-and-studio-practice.md @@ -0,0 +1,256 @@ +# Critique and studio practice + +Critique is not a verdict and not a taste contest. It is a structured method for making +design intent, formal decisions, user consequences, and possible revisions visible. + +## Contents + +1. What critique is for +2. The critique sequence +3. Five lenses +4. Giving useful feedback +5. Receiving and using feedback +6. Studio formats +7. Revision discipline +8. Exercises and assessment +9. Sources + +## 1. What critique is for + +A studio critique should help a designer: + +- see what the artifact actually communicates, not only what was intended; +- connect formal decisions to human consequences; +- distinguish a local defect from a systemic problem; +- generate alternatives instead of defending the first solution; +- learn a transferable principle; +- make the next iteration more deliberate. + +Good critique evaluates the relationship: + +`intent → artifact → perception → action → consequence` + +Do not collapse that relationship into “I like it” or “make it pop.” + +## 2. The critique sequence + +### 2.1 Frame + +The designer states: + +- audience and situation; +- user goal and product goal; +- constraints; +- maturity of the work; +- the question on which feedback is needed. + +This takes one or two minutes. A long defense prevents observers from seeing the work +independently. + +### 2.2 Observe + +Describe visible evidence without interpretation: + +- “The page contains three primary-colored actions.” +- “The heading and body copy share nearly the same size.” +- “The form changes from one column to two columns.” + +Observation establishes common ground. + +### 2.3 Interpret + +Explain the meaning a viewer may construct: + +- “The equal emphasis makes all three actions appear equally important.” +- “The weak size contrast makes the title read like body copy.” + +Mark interpretation as interpretation, not fact. + +### 2.4 Connect to intent and principle + +Relate the observation to the stated goal and a relevant principle: + +- hierarchy; +- grouping; +- readability; +- feedback; +- error prevention; +- accessibility; +- trust; +- consistency. + +### 2.5 Ask + +Use questions to expose reasoning: + +- What must a first-time user notice in five seconds? +- Which action is irreversible? +- What evidence made this hierarchy appropriate? +- What happens with long translated text? + +Questions are useful when they investigate rather than disguise a command. + +### 2.6 Propose and test + +Suggest a direction, not an unexamined pixel prescription: + +- reduce the number of competing emphasis levels; +- group controls by task; +- show validation beside the affected field; +- prototype two orderings and compare task success. + +End with a concrete revision hypothesis. + +## 3. Five critique lenses + +### Formal + +Composition, scale, rhythm, proportion, alignment, contrast, color, typography, image, +space, and visual hierarchy. + +### Semantic + +Meaning, tone, labels, icon comprehension, cultural interpretation, and whether visual +form fits the subject. + +### Behavioral + +Affordance, signifiers, feedback, mapping, state transitions, error recovery, and the +continuity of the user’s mental model. + +### Systemic + +Reuse, component logic, token consistency, responsive behavior, content variability, +edge cases, and relationship to the wider product. + +### Human and ethical + +Accessibility, inclusion, cognitive load, autonomy, privacy, power, risk, and whether the +interface manipulates or excludes. + +Choose the lens explicitly. A formal study should not be judged as if it were a finished +product, and a usability prototype should not escape behavioral scrutiny because it looks +polished. + +## 4. Giving useful feedback + +Use this structure: + +> I observe **X**. In the context of **goal or user**, this may cause **Y**, because +> **principle or evidence**. I would test **direction Z**. + +Rank findings: + +- **Critical:** prevents completion, creates serious harm, or violates an essential + accessibility requirement. +- **Major:** materially weakens comprehension, efficiency, trust, or consistency. +- **Moderate:** causes friction but has a workable path around it. +- **Minor:** refinement with limited effect on outcome. + +Avoid: + +- unexplained personal preference; +- redesigning the work in the critic’s own style; +- comments detached from audience and context; +- presenting a platform convention as universal law; +- flooding an early concept with polish-level notes; +- praising without explaining what should be preserved. + +## 5. Receiving and using feedback + +The designer should: + +1. listen and record before answering; +2. ask for evidence or clarification; +3. separate intent from the artifact’s observed effect; +4. cluster repeated signals; +5. decide rather than mechanically obey; +6. document accepted, rejected, and deferred findings with reasons; +7. return with a visible iteration. + +Critique is evidence, not authority. The designer remains responsible for synthesis. + +## 6. Studio formats + +### Pin-up + +Several artifacts are displayed together. Useful for comparison of formal studies and +rapid pattern recognition. + +### Roundtable + +One project is discussed in depth. Useful for complex interaction and system questions. + +### Silent critique + +Reviewers annotate before discussion. Useful for reducing anchoring and giving quieter +participants independent space. + +### Pair critique + +Two designers alternate presenter and critic roles. Useful for frequent work-in-progress +feedback. + +### Expert review + +A specialist evaluates a specific domain such as accessibility, content, research, +security, or data visualization. + +### User evaluation + +Representative users attempt tasks. This is not the same as peer critique: participant +behavior is evidence about use, not a request that participants design the solution. + +## 7. Revision discipline + +Maintain a critique ledger: + +| Finding | Evidence | Severity | Decision | Revision | Result | +| --- | --- | --- | --- | --- | --- | +| Competing actions | 4/5 reviewers chose different first action | Major | Accept | Establish one primary action | Re-test | + +For each substantial revision, preserve before and after states and write: + +- what changed; +- why it changed; +- what principle or evidence informed it; +- what improved; +- what new risk appeared. + +A portfolio should show this reasoning, not only the final surface. + +## 8. Exercises and assessment + +### Exercise A: description without judgment + +Describe one screen for five minutes without using good, bad, clean, intuitive, modern, +or beautiful. Then identify which observations can be measured. + +### Exercise B: five-lens critique + +Critique one artifact separately through all five lenses. Do not mix them until the end. + +### Exercise C: revision proof + +Produce before/after versions plus a one-page decision record. Include one piece of +feedback you rejected and why. + +### Rubric + +| Criterion | Developing | Competent | Advanced | +| --- | --- | --- | --- | +| Observation | Taste claims dominate | Visible evidence is named | Evidence is precise and pattern-aware | +| Reasoning | Advice lacks a cause | Finding connects to goal and principle | Competing interpretations are examined | +| Prioritization | All issues seem equal | Severity and scope are distinguished | Dependencies and systemic causes are mapped | +| Revision | Feedback is copied literally | Findings become deliberate changes | Alternatives are tested and learning transfers | +| Studio conduct | Defensive or prescriptive | Specific, respectful exchange | Builds shared vocabulary and inquiry | + +## 9. Sources + +- [Carnegie Mellon School of Design: BDes learning outcomes](https://www.design.cmu.edu/about-our-programs/undergraduate-degrees/learning-outcomes-bdes-degree-program) +- [Carnegie Mellon School of Design: MA learning outcomes](https://www.design.cmu.edu/sites/default/files/2025-11/MA_learning%20outcomes.pdf) +- [RISD Graphic Design BFA](https://www.risd.edu/academics/graphic-design/bachelors-program) +- [Yale School of Art: Graphic Design](https://www.art.yale.edu/about/study-areas/graduate-study-areas/graphic-design) +- Donald Schön, *The Reflective Practitioner*. +- Terry Barrett, *Criticizing Art: Understanding the Contemporary*. +- Liz Lerman, *Critical Response Process*. diff --git a/.agents/skills/interface-design/references/16-ethics-and-ai-interfaces.md b/.agents/skills/interface-design/references/16-ethics-and-ai-interfaces.md new file mode 100644 index 000000000..482721d9e --- /dev/null +++ b/.agents/skills/interface-design/references/16-ethics-and-ai-interfaces.md @@ -0,0 +1,249 @@ +# Ethics, responsible design, and AI interfaces + +Interface design distributes attention, opportunity, risk, and control. Ethical judgment +is therefore part of professional design practice, not an optional review after visual +decisions have been made. + +## Contents + +1. Ethical frame +2. Autonomy and deceptive design +3. Privacy, consent, and data +4. Inclusion, fairness, and power +5. Safety and high-stakes contexts +6. Principles for AI interfaces +7. AI interaction patterns +8. Responsible review +9. Exercises and assessment +10. Sources + +## 1. Ethical frame + +Review a product across four layers: + +1. **Intent:** whose goal is being optimized? +2. **Mechanism:** what behavior does the interface encourage or obstruct? +3. **Distribution:** who receives benefit, cost, risk, and decision power? +4. **Consequence:** what happens in normal use, error, misuse, exclusion, and scale? + +Compliance is a minimum constraint, not proof of ethical quality. A lawful interaction +can still be coercive, inaccessible, misleading, or socially harmful. + +Use these values as explicit criteria: + +- human agency; +- dignity; +- fairness; +- privacy; +- transparency; +- accessibility; +- safety; +- contestability; +- accountability; +- sustainability. + +## 2. Autonomy and deceptive design + +An interface undermines autonomy when it materially distorts a person’s ability to make +an informed, voluntary choice. + +Warning signs include: + +- hidden or delayed costs; +- preselected consent; +- asymmetric accept and decline paths; +- difficult cancellation; +- false scarcity or urgency; +- disguised advertising; +- confusing double negatives; +- obstruction through repeated steps; +- emotional pressure unrelated to the decision; +- silently expanding the scope of permission. + +Apply the symmetry test: + +- Are acceptance and refusal equally understandable? +- Are consequences disclosed before commitment? +- Is reversal reasonably proportional to enrollment? +- Does the design remain honest when business pressure rises? + +The FTC’s dark-patterns report is a regulatory source and taxonomy aid. Do not reduce +ethical review to a finite blacklist; manipulation can take new forms. + +## 3. Privacy, consent, and data + +Design privacy as an interaction property: + +- collect only what is necessary for the stated purpose; +- explain purpose at the moment of decision; +- separate required from optional data; +- avoid bundling unrelated permissions; +- make retention, sharing, export, and deletion understandable; +- allow review and revocation; +- protect sensitive information from accidental exposure; +- design safe defaults. + +Consent should be informed, specific, freely given, and reversible. A link to a long +policy does not repair an interface whose immediate choice is misleading. + +Create a data journey: + +`collection → inference → storage → access → sharing → retention → deletion` + +At each stage identify the user expectation, actual system behavior, risk, and control. + +## 4. Inclusion, fairness, and power + +Ask who is: + +- represented in research and data; +- absent from default personas; +- expected to adapt to the system; +- disproportionately exposed to error; +- able to appeal; +- unable to refuse; +- affected without being a direct user. + +Fairness is contextual. Equal treatment can preserve an unequal outcome, while different +support may be needed for equitable access. Do not claim that a visual treatment “solves +bias”; investigate policies, data, operations, and institutional incentives. + +Use participatory methods when communities bear meaningful consequences. Compensation, +accessible participation, feedback loops, and visible influence on decisions matter. + +## 5. Safety and high-stakes contexts + +For health, finance, employment, education, legal, public-service, and safety-critical +interfaces: + +- state the limits of the system; +- distinguish advice, estimate, and authoritative decision; +- prevent irreversible action by accident; +- preserve an audit trail where appropriate; +- support escalation to qualified humans; +- design for stress, low attention, and incomplete information; +- test rare but severe failure conditions; +- avoid translating uncertain evidence into false precision. + +Risk is a combination of probability, severity, exposure, reversibility, and affected +population. A rare irreversible harm may deserve priority over common cosmetic friction. + +## 6. Principles for AI interfaces + +AI introduces variable, probabilistic behavior. The interface must help people form an +accurate working model without implying certainty or agency the system does not possess. + +### Disclose appropriately + +Make AI involvement clear when it materially affects interpretation, choice, authorship, +or consequence. Disclosure must be understandable and timely, not buried in settings. + +### Calibrate trust + +Communicate capability, limitation, uncertainty, and freshness. Avoid both magical +framing and blanket warnings that users learn to ignore. + +### Preserve control + +Support preview, edit, regenerate, undo, stop, opt out, and manual alternatives in +proportion to risk. + +### Make correction productive + +Let users identify what is wrong, repair the result, and understand whether feedback +changes the current artifact, future behavior, or neither. + +### Show provenance where useful + +For consequential claims, distinguish sources, system inference, user input, and generated +content. Do not use citations as decoration; they should resolve to supporting evidence. + +### Design for failure + +Cover refusal, hallucination, ambiguity, latency, partial completion, unsafe requests, +model unavailability, stale knowledge, and handoff to another mode or person. + +### Avoid anthropomorphic deception + +Conversational warmth can improve usability, but do not imply feelings, consciousness, +memory, confidentiality, or authority the system does not have. + +## 7. AI interaction patterns + +### Generative workspace + +Keep prompt, context, output, edit history, and accepted changes distinguishable. Let the +user compare versions and preserve authored work. + +### Recommendation + +Explain what the recommendation optimizes, provide meaningful alternatives, and make +important exclusions visible. Users need a path to change the criteria. + +### Classification or score + +Show the decision’s purpose, relevant uncertainty, known limits, and a route to challenge +or correct inputs. Never present a score as a complete account of a person. + +### Agentic action + +Before consequential external action, make scope, target, side effect, and reversibility +clear. Use staged authorization and receipts. Separate proposing, preparing, and executing. + +### Memory and personalization + +Show what is remembered, why it is used, how to edit it, and how to disable or delete it. +Do not let conversational convenience obscure data persistence. + +## 8. Responsible review + +Create an impact record: + +| Dimension | Question | Evidence | Risk | Mitigation | Owner | +| --- | --- | --- | --- | --- | --- | +| Agency | Can the person refuse or reverse? | Flow review | High | Add manual path and undo | Product | +| Transparency | Is AI involvement understood? | Comprehension study | Medium | Contextual disclosure | Design | +| Fairness | Who has elevated error rates? | Segmented evaluation | High | Data and policy review | ML/Policy | + +Run review before commitment, before launch, and after real-world evidence emerges. +Record unresolved risks rather than converting uncertainty into a green checkmark. + +## 9. Exercises and assessment + +### Exercise A: dark-pattern reversal + +Select a manipulative flow. Diagram the business mechanism, user harm, and power +asymmetry. Redesign it while preserving a legitimate business objective. + +### Exercise B: AI failure matrix + +For one AI feature, map normal, ambiguous, low-confidence, unsafe, unavailable, and +irreversible cases. Design the interface response for each. + +### Exercise C: stakeholder impact map + +Map direct users, indirect subjects, operators, affected communities, purchasers, and +regulators. Identify who can challenge the system. + +### Rubric + +| Criterion | Developing | Competent | Advanced | +| --- | --- | --- | --- | +| Ethical framing | Relies on intention | Names stakeholders and consequences | Exposes power, incentives, and systemic effects | +| Agency | Adds generic consent | Supports informed choice and reversal | Calibrates control to risk and dependency | +| AI transparency | Adds an AI badge | Explains role and key limits | Calibrates explanation to consequence | +| Failure design | Covers one error message | Covers major failure states | Links interface, policy, operations, and escalation | +| Evidence | Makes broad fairness claims | Uses research and segmented evidence | Records uncertainty and ongoing monitoring | + +## 10. Sources + +- [NIST AI Risk Management Framework](https://www.nist.gov/itl/ai-risk-management-framework) +- [NIST AI RMF 1.0 publication](https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-ai-rmf-10) +- [OECD AI Principles](https://oecd.ai/en/principles) +- [OECD principle on transparency and explainability](https://oecd.ai/en/dashboards/ai-principles/P7) +- [Federal Trade Commission: Bringing Dark Patterns to Light](https://www.ftc.gov/reports/bringing-dark-patterns-light) +- [Microsoft Inclusive Design](https://inclusive.microsoft.design/) +- [W3C Web Accessibility Initiative](https://www.w3.org/WAI/) +- Sasha Costanza-Chock, *Design Justice*. +- Mike Monteiro, *Ruined by Design*. +- Batya Friedman and David Hendry, *Value Sensitive Design*. diff --git a/.agents/skills/interface-design/references/17-curriculum-and-exercises.md b/.agents/skills/interface-design/references/17-curriculum-and-exercises.md new file mode 100644 index 000000000..970160da0 --- /dev/null +++ b/.agents/skills/interface-design/references/17-curriculum-and-exercises.md @@ -0,0 +1,320 @@ +# Curriculum and exercise system + +This curriculum turns the library into a studio course. It starts with perception and +form, advances through communication and interaction, and ends with an evidence-based +system project. Tools are secondary; observation, judgment, iteration, and articulation +are the core capabilities. + +## Contents + +1. Learning model +2. Twenty-four-week curriculum +3. Assignment anatomy +4. Critique cadence +5. Assessment +6. Shorter study paths +7. Capstone +8. Teaching and mentoring protocol + +## 1. Learning model + +Each unit follows: + +`study → observe → reproduce → vary → apply → critique → revise → reflect` + +- **Study:** learn vocabulary, principles, history, and evidence. +- **Observe:** analyze existing artifacts without immediately redesigning them. +- **Reproduce:** reconstruct a formal or behavioral principle to understand it. +- **Vary:** change one variable at a time and compare consequences. +- **Apply:** use the principle in a new problem. +- **Critique:** expose decisions to structured review. +- **Revise:** make learning visible through change. +- **Reflect:** state what transfers to the next problem. + +Require a process record. Finished screens alone cannot demonstrate design learning. + +## 2. Twenty-four-week curriculum + +### Phase I — Foundations of seeing and form + +#### Week 1: Seeing, describing, and framing + +- Read: modules 00, 01, and 15. +- Studio: describe six interfaces without evaluative adjectives. +- Artifact: observation sheets and a visual inventory. +- Learning outcome: separate evidence, interpretation, and judgment. + +#### Week 2: Point, line, plane, figure, and ground + +- Read: module 01. +- Studio: produce 20 black-and-white compositions from identical elements. +- Constraint: no type, color, gradients, or illustration. +- Learning outcome: control balance, tension, scale, grouping, and negative space. + +#### Week 3: Hierarchy and visual rhythm + +- Studio: organize the same content into three distinct reading sequences. +- Artifact: annotated hierarchy maps. +- Test: five-second recall with peers. +- Learning outcome: direct attention through relational contrast. + +#### Week 4: Typography as structure + +- Read: module 02. +- Studio: one text, six typographic interpretations. +- Constraint: one type family; no imagery. +- Learning outcome: use spacing, measure, scale, and weight before decoration. + +#### Week 5: Color as relationship + +- Read: module 03. +- Studio: simultaneous-contrast studies followed by one semantic interface palette. +- Artifact: grayscale and color versions plus contrast evidence. +- Learning outcome: distinguish optical, semantic, and accessibility roles of color. + +#### Week 6: Grid, space, and responsive transformation + +- Read: module 04. +- Studio: design one editorial composition at narrow, medium, and wide widths. +- Constraint: explain transformations; do not merely shrink. +- Learning outcome: construct and deliberately break a spatial system. + +### Phase II — Communication and information + +#### Week 7: Perception and attention + +- Read: module 05. +- Studio: annotate grouping, scanning, recognition, memory, and competing signals. +- Learning outcome: connect visual form to perceptual consequence. + +#### Week 8: Content hierarchy and information architecture + +- Read: modules 07 and 13. +- Studio: card sort, taxonomy draft, and navigation model for a small domain. +- Learning outcome: organize by user language and task, not organizational structure. + +#### Week 9: Wayfinding and retrieval + +- Studio: create browse, search, filtering, and recovery paths for the same collection. +- Artifact: sitemap, task flows, and state map. +- Learning outcome: distinguish location, destination, and next action. + +#### Week 10: Information visualization + +- Read: relevant parts of module 14. +- Studio: encode one dataset three ways; document what each reveals and hides. +- Learning outcome: select visual form from analytical task and data type. + +### Phase III — Human-centered interaction + +#### Week 11: Research questions and study design + +- Read: module 06. +- Studio: turn assumptions into research questions and choose methods. +- Artifact: protocol, sampling rationale, consent language, and analysis plan. +- Learning outcome: align evidence with the decision it must inform. + +#### Week 12: Field research and synthesis + +- Studio: conduct at least three sessions; preserve observations separately from themes. +- Artifact: evidence wall, patterns, contradictions, and opportunity statements. +- Learning outcome: synthesize without erasing uncertainty or minority evidence. + +#### Week 13: Concept generation + +- Studio: breadth before convergence—at least 20 small concepts, then three systems. +- Constraint: each concept must change the interaction model, not just style. +- Learning outcome: avoid premature attachment to the first plausible solution. + +#### Week 14: Interaction models and state + +- Read: module 08. +- Studio: create conceptual model, state diagram, and low-fidelity task prototype. +- Learning outcome: make system behavior predictable and recoverable. + +#### Week 15: Components, forms, and errors + +- Read: module 09. +- Studio: one form across default, focus, invalid, pending, success, disabled, and + interrupted states. +- Learning outcome: design the full lifecycle rather than the ideal path. + +#### Week 16: Usability evaluation + +- Read: testing portions of module 06 and critique in module 15. +- Studio: task-based sessions with behavioral observations and severity ranking. +- Artifact: findings, evidence clips or notes, revisions, and re-test plan. +- Learning outcome: distinguish participant behavior from requested solutions. + +### Phase IV — Professional systems + +#### Week 17: Accessibility and inclusion + +- Read: module 11. +- Studio: keyboard, zoom, contrast, screen-reader structure, and cognitive walkthrough. +- Learning outcome: integrate accessibility into design decisions and acceptance criteria. + +#### Week 18: Design systems + +- Read: module 10. +- Studio: derive tokens, components, and patterns from several product screens. +- Learning outcome: identify recurrence and encode deliberate constraints. + +#### Week 19: Content, localization, and resilience + +- Read: module 13. +- Studio: rewrite labels, instructions, errors, and empty states; stress with expansion, + missing data, and plural forms. +- Learning outcome: treat language and content variability as structural. + +#### Week 20: Motion and temporal behavior + +- Read: module 12. +- Studio: storyboard one transition with and without motion; support reduced motion. +- Learning outcome: use time to explain cause, hierarchy, and continuity. + +#### Week 21: Ethics and responsible AI + +- Read: module 16. +- Studio: impact assessment plus a failure matrix for a consequential AI-assisted flow. +- Learning outcome: preserve agency and calibrate trust under uncertainty. + +#### Week 22: Domain depth + +- Read: module 14 or a domain-specific source set. +- Studio: data-dense dashboard, commerce journey, or comparable complex workflow. +- Learning outcome: apply foundations under real information and business constraints. + +#### Week 23: Integrated capstone critique + +- Present: research evidence, system model, prototype, accessibility review, and open risks. +- Critique: formal, behavioral, systemic, and ethical lenses. +- Learning outcome: defend decisions through evidence while remaining revisable. + +#### Week 24: Revision and public defense + +- Deliver: final prototype, specification, process book, and oral defense. +- Reflect: which decisions changed, what remains uncertain, and what should be measured. +- Learning outcome: demonstrate judgment, integration, and reflective practice. + +## 3. Assignment anatomy + +Every assignment should specify: + +- **purpose:** capability being developed; +- **question:** conceptual problem, not a screen list; +- **context:** audience, situation, and constraints; +- **material:** content, data, or research evidence; +- **constraints:** variables intentionally limited; +- **deliverables:** artifacts and process evidence; +- **critique stage:** what kind of feedback is appropriate; +- **revision:** required response to critique; +- **transfer:** a second context that tests generalization; +- **rubric:** observable criteria. + +Prefer constraints that isolate a principle. “Use only type and space” teaches hierarchy +more clearly than an unconstrained request for a polished landing page. + +## 4. Critique cadence + +Use three reviews: + +1. **Divergence review:** breadth, framing, and genuinely different models. +2. **Structure review:** hierarchy, behavior, evidence, and system logic. +3. **Refinement review:** content, accessibility, edge cases, craft, and specification. + +Do not postpone critique until the artifact is expensive to change. Require visible +revision after each review. + +## 5. Assessment + +Recommended weighting: + +| Dimension | Weight | +| --- | ---: | +| Framing and research | 15% | +| Formal and communication quality | 20% | +| Interaction and system reasoning | 20% | +| Accessibility and responsibility | 15% | +| Exploration and iteration | 15% | +| Articulation and critique practice | 10% | +| Craft and delivery | 5% | + +Use four levels: + +- **Beginning:** applies isolated rules without reliable control. +- **Developing:** demonstrates the principle but inconsistently. +- **Competent:** makes coherent decisions appropriate to context. +- **Advanced:** manages tensions, exceptions, and systemic consequences. + +Assess process and outcome together. Quantity of screens is not a measure of learning. + +## 6. Shorter study paths + +### Eight-week foundations + +Modules 01–05, 13, and 15. End with a responsive editorial/interface composition. + +### Twelve-week interface design + +Modules 01–09, 11, 13, and 15. End with a tested multi-step service flow. + +### Six-week professional intensive + +Diagnostic in week 1, then modules selected around gaps. Require a real project, weekly +critique, accessibility review, and before/after case study. + +### Self-study + +Work in two-week blocks. Find at least two peers for critique. Never grade only by visual +comparison to fashionable examples; use the module rubrics and behavioral evidence. + +## 7. Capstone + +The capstone must include: + +- a consequential problem with identifiable stakeholders; +- primary or carefully justified secondary research; +- information architecture and task/state models; +- multiple concept directions; +- an interactive prototype covering failure and edge states; +- content and localization considerations; +- accessibility evidence; +- design-system logic appropriate to scale; +- an ethical or impact assessment; +- user evaluation and revision; +- a process book with sources and decision records. + +The final defense should answer: + +1. What evidence changed the project? +2. Which trade-off was hardest? +3. Who benefits and who bears risk? +4. How does the form support meaning? +5. Where can the interaction fail? +6. What is intentionally unresolved? +7. What would be measured after release? + +## 8. Teaching and mentoring protocol + +When teaching from this library: + +1. diagnose the learner’s current level with a small artifact; +2. select one primary principle and at most two supporting principles; +3. explain with contrasting examples; +4. assign a constrained study before a product-scale task; +5. critique using observations and questions; +6. require a revision and reflection; +7. increase complexity only after control becomes visible. + +Do not replace the learner’s judgment with a finished solution. Demonstrate alternatives, +name consequences, and ask the learner to choose and justify. + +For academic program precedents, see: + +- [Carnegie Mellon School of Design undergraduate learning outcomes](https://www.design.cmu.edu/about-our-programs/undergraduate-degrees/learning-outcomes-bdes-degree-program) +- [Carnegie Mellon Master of Design: Design for Interactions](https://www.design.cmu.edu/about-our-programs/masters-degrees/master-design-design-interactions) +- [RISD Graphic Design BFA](https://www.risd.edu/academics/graphic-design/bachelors-program) +- [Yale School of Art: Graphic Design](https://www.art.yale.edu/about/study-areas/graduate-study-areas/graphic-design) +- [MIT OpenCourseWare: User Interface Design and Implementation](https://ocw.mit.edu/courses/6-831-user-interface-design-and-implementation-spring-2011/) +- [UC San Diego Design and Interaction major](https://cogsci.ucsd.edu/undergraduates/major/design-interaction.html) diff --git a/.agents/skills/interface-design/references/18-bibliography.md b/.agents/skills/interface-design/references/18-bibliography.md new file mode 100644 index 000000000..93dff21c4 --- /dev/null +++ b/.agents/skills/interface-design/references/18-bibliography.md @@ -0,0 +1,222 @@ +# Curated bibliography and authoritative source index + +This is a working canon, not a claim that design has one universal canon. It combines +primary standards, university curricula, durable research, and influential professional +texts. Use `00-source-policy.md` to assess claims and resolve conflicts. + +## Contents + +1. How to use this bibliography +2. Academic program precedents +3. Standards and public guidance +4. Visual design and typography +5. Color, perception, and cognition +6. HCI and interaction design +7. Research and information architecture +8. Design systems, accessibility, and inclusion +9. Content, motion, and data visualization +10. Ethics and responsible technology +11. Living professional systems + +## 1. How to use this bibliography + +- Read standards for requirements and testable criteria. +- Read research for evidence and limitations. +- Read canonical books for models, vocabulary, history, and practice. +- Read design systems as contextual implementations, not universal laws. +- Read university curricula to understand sequencing and learning outcomes. +- Verify edition, publication date, and current status before citing a time-sensitive fact. +- Prefer the direct source over a summary when a decision has material consequences. + +Books are cited as bibliographic leads; this library does not reproduce copyrighted text. + +## 2. Academic program precedents + +- [Carnegie Mellon School of Design: BDes learning outcomes](https://www.design.cmu.edu/about-our-programs/undergraduate-degrees/learning-outcomes-bdes-degree-program) +- [Carnegie Mellon School of Design: Design for Interactions](https://www.design.cmu.edu/about-our-programs/masters-degrees/master-design-design-interactions) +- [Carnegie Mellon METALS curriculum](https://metals.hcii.cmu.edu/curriculum/) +- [Carnegie Mellon School of Design: Communications](https://design.cmu.edu/about-our-programs/undergraduate-degrees/communications) +- [RISD Graphic Design BFA](https://www.risd.edu/academics/graphic-design/bachelors-program) +- [RISD Graphic Design courses](https://www.risd.edu/academics/graphic-design/courses?page=1) +- [Yale School of Art: Graphic Design](https://www.art.yale.edu/about/study-areas/graduate-study-areas/graphic-design) +- [Yale Preliminary Studio](https://www.art.yale.edu/art710a) +- [MIT OpenCourseWare: Art of Color](https://ocw.mit.edu/courses/es-298-art-of-color-spring-2005/pages/syllabus/) +- [MIT OpenCourseWare: User Interface Design and Implementation](https://ocw.mit.edu/courses/6-831-user-interface-design-and-implementation-spring-2011/) +- [UC San Diego: Design and Interaction](https://cogsci.ucsd.edu/undergraduates/major/design-interaction.html) +- [UC San Diego: Information Visualization](https://hci.ucsd.edu/220/) + +## 3. Standards and public guidance + +- [ISO 9241-210:2019 — Human-centred design for interactive systems](https://www.iso.org/standard/77520.html) +- [W3C Web Content Accessibility Guidelines overview](https://www.w3.org/WAI/standards-guidelines/wcag/) +- [W3C WCAG 2.2](https://www.w3.org/TR/WCAG22/) +- [W3C ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) +- [W3C cognitive accessibility](https://www.w3.org/WAI/cognitive/) +- [W3C supplemental accessibility guidance](https://www.w3.org/WAI/WCAG2/supplemental/) +- [NIST AI Risk Management Framework](https://www.nist.gov/itl/ai-risk-management-framework) +- [OECD AI Principles](https://oecd.ai/en/principles) +- [US Web Design System](https://designsystem.digital.gov/) +- [GOV.UK Design System](https://design-system.service.gov.uk/) + +## 4. Visual design and typography + +### Form, composition, and graphic language + +- Josef Albers, *Interaction of Color*. +- Wucius Wong, *Principles of Form and Design*. +- Ellen Lupton and Jennifer Cole Phillips, *Graphic Design: The New Basics*. +- György Kepes, *Language of Vision*. +- Rudolf Arnheim, *Art and Visual Perception*. +- Donis A. Dondis, *A Primer of Visual Literacy*. +- Kimberly Elam, *Geometry of Design*. +- Bruno Munari, *Design as Art*. + +### Grid and systems + +- Josef Müller-Brockmann, *Grid Systems in Graphic Design*. +- Timothy Samara, *Making and Breaking the Grid*. +- Karl Gerstner, *Designing Programmes*. +- Richard Hollis, *Swiss Graphic Design*. + +### Typography + +- Robert Bringhurst, *The Elements of Typographic Style*. +- Ellen Lupton, *Thinking with Type*. +- Emil Ruder, *Typography*. +- Erik Spiekermann, *Stop Stealing Sheep & Find Out How Type Works*. +- Jan Middendorp, *Shaping Text*. +- Sofie Beier, *Reading Letters: Designing for Legibility*. +- Karen Cheng, *Designing Type*. + +## 5. Color, perception, and cognition + +### Color + +- Johannes Itten, *The Art of Color*. +- Josef Albers, *Interaction of Color*. +- Maureen Stone, *A Field Guide to Digital Color*. +- Rolf G. Kuehni, *Color: An Introduction to Practice and Principles*. + +### Perception and cognition + +- Colin Ware, *Visual Thinking for Design*. +- Colin Ware, *Information Visualization: Perception for Design*. +- Daniel Kahneman, *Thinking, Fast and Slow*. +- Christopher Wickens et al., *Engineering Psychology and Human Performance*. +- Jeff Johnson, *Designing with the Mind in Mind*. +- Alan Dix et al., *Human-Computer Interaction*. + +Use cognitive principles as explanatory models, not as slogans or guaranteed formulas. + +## 6. HCI and interaction design + +- Don Norman, *The Design of Everyday Things*. +- Bill Moggridge, *Designing Interactions*. +- Helen Sharp, Jennifer Preece, and Yvonne Rogers, *Interaction Design*. +- Ben Shneiderman et al., *Designing the User Interface*. +- Alan Cooper et al., *About Face: The Essentials of Interaction Design*. +- Dan Saffer, *Designing for Interaction*. +- Brenda Laurel, ed., *The Art of Human-Computer Interface Design*. +- Stuart Card, Thomas Moran, and Allen Newell, *The Psychology of Human-Computer Interaction*. +- [Nielsen Norman Group: Ten Usability Heuristics](https://www.nngroup.com/articles/ten-usability-heuristics/) + +## 7. Research and information architecture + +### Research + +- Steve Portigal, *Interviewing Users*. +- Erika Hall, *Just Enough Research*. +- Martin and Hanington, *Universal Methods of Design*. +- Leah Buley, *The User Experience Team of One*. +- Tomer Sharon, *Validating Product Ideas*. +- Caroline Jarrett and Steve Krug, *Forms That Work*. +- [Nielsen Norman Group: UX Research Methods](https://www.nngroup.com/articles/which-ux-research-methods/) +- [IDEO Design Kit](https://www.designkit.org/) + +### Information architecture + +- Louis Rosenfeld, Peter Morville, and Jorge Arango, *Information Architecture for the + Web and Beyond*. +- Abby Covert, *How to Make Sense of Any Mess*. +- Donna Spencer, *A Practical Guide to Information Architecture*. +- Jorge Arango, *Living in Information*. +- Indi Young, *Mental Models*. + +## 8. Design systems, accessibility, and inclusion + +### Design systems + +- Alla Kholmatova, *Design Systems*. +- Nathan Curtis, *Modular Web Design*. +- Yesenia Perez-Cruz, *Expressive Design Systems*. +- Brad Frost, *Atomic Design*. +- [W3C Design Tokens Community Group](https://www.w3.org/community/design-tokens/) +- [IBM Carbon Design System](https://carbondesignsystem.com/) +- [Material Design 3](https://m3.material.io/) +- [Microsoft Fluent 2](https://fluent2.microsoft.design/) + +### Accessibility and inclusive design + +- Kat Holmes, *Mismatch: How Inclusion Shapes Design*. +- Regine Gilbert, *Inclusive Design for a Digital World*. +- Sarah Horton and Whitney Quesenbery, *A Web for Everyone*. +- Laura Kalbag, *Accessibility for Everyone*. +- [Microsoft Inclusive Design](https://inclusive.microsoft.design/) +- [Apple HIG: Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility) +- [IBM Carbon: Accessibility](https://carbondesignsystem.com/guidelines/accessibility/overview/) + +## 9. Content, motion, and data visualization + +### Content design + +- Sarah Richards, *Content Design*. +- Torrey Podmajersky, *Strategic Writing for UX*. +- Kinneret Yifrah, *Microcopy: The Complete Guide*. +- Ginny Redish, *Letting Go of the Words*. +- [GOV.UK Style Guide](https://www.gov.uk/guidance/style-guide) +- [IBM Carbon content guidelines](https://carbondesignsystem.com/guidelines/content/overview/) + +### Motion + +- Val Head, *Designing Interface Animation*. +- Rachel Nabors, *Animation at Work*. +- [Apple HIG: Motion](https://developer.apple.com/design/human-interface-guidelines/motion) +- [Material Design 3: Motion](https://m3.material.io/styles/motion/overview) +- [W3C: Animation from interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) + +### Data visualization + +- Edward Tufte, *The Visual Display of Quantitative Information*. +- Tamara Munzner, *Visualization Analysis and Design*. +- Stephen Few, *Information Dashboard Design*. +- Isabel Meirelles, *Design for Information*. +- Alberto Cairo, *The Functional Art*. +- Jacques Bertin, *Semiology of Graphics*. + +## 10. Ethics and responsible technology + +- Sasha Costanza-Chock, *Design Justice*. +- Batya Friedman and David Hendry, *Value Sensitive Design*. +- Mike Monteiro, *Ruined by Design*. +- Arturo Escobar, *Designs for the Pluriverse*. +- Virginia Eubanks, *Automating Inequality*. +- Safiya Umoja Noble, *Algorithms of Oppression*. +- [Federal Trade Commission: Bringing Dark Patterns to Light](https://www.ftc.gov/reports/bringing-dark-patterns-light) +- [NIST AI RMF 1.0](https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-ai-rmf-10) +- [OECD transparency and explainability principle](https://oecd.ai/en/dashboards/ai-principles/P7) + +## 11. Living professional systems + +These sources change. Check the current version and platform context before applying them: + +- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) +- [Material Design 3](https://m3.material.io/) +- [Microsoft Fluent 2](https://fluent2.microsoft.design/) +- [IBM Carbon Design System](https://carbondesignsystem.com/) +- [GOV.UK Design System](https://design-system.service.gov.uk/) +- [US Web Design System](https://designsystem.digital.gov/) +- [Baymard Institute ecommerce research](https://baymard.com/research) + +Use these systems to study decisions, constraints, component states, content, and +accessibility. Do not imitate their visual style without understanding the problem and +platform for which the guidance was created. diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..a16ee0eb3 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,40 @@ +[mcp_servers.shopana-cli] +command = "node" +args = ["./packages/shopana-cli-mcp/dist/index.js"] + +[mcp_servers.shopana-cli.tools.shopana_list_services] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_db_generate] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_codegen] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_schema] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_admin] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_build] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_test] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_get_e2e_test_command] +approval_mode = "approve" + +[mcp_servers.shopana-cli.tools.shopana_project_info] +approval_mode = "approve" + +[mcp_servers.antd] +approval_mode = "approve" +command = "npx" +args = ["-y", "@jzone-mcp/antd-components-mcp"] + +[mcp_servers.ag-mcp] +approval_mode = "approve" +command = "npx" +args = ["ag-mcp"] diff --git a/.gitignore b/.gitignore index f68223939..d9de6751a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ infra/federation/schema/ test-results/ .external/ +.tmp/ + +admin-old/ diff --git a/.mcp.json b/.mcp.json index dec574516..938d8bbbe 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,8 +2,7 @@ "mcpServers": { "shopana-cli": { "command": "node", - "args": ["/Users/phl/Projects/shopana-io/services/packages/shopana-cli-mcp/dist/index.js"], - "cwd": "/Users/phl/Projects/shopana-io/services/packages/shopana-cli-mcp" + "args": ["./packages/shopana-cli-mcp/dist/index.js"] }, "shopana-index": { "command": "npx", diff --git a/.zed/tasks.json b/.zed/tasks.json index 55c9e12db..850e5845f 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -1,4 +1,17 @@ [ + { + "label": "Run Full Dev Stack", + "command": "source ~/.nvm/nvm.sh && nvm use 22 && docker compose up -d postgres minio && until [ \"$(docker inspect -f '{{.State.Health.Status}}' shopana-postgres 2>/dev/null)\" = healthy ] && [ \"$(docker inspect -f '{{.State.Health.Status}}' shopana-minio 2>/dev/null)\" = healthy ]; do sleep 1; done && docker compose up minio-init && yarn shopana db migrate && { yarn shopana gateway & gateway_pid=$!; yarn shopana dev & bootstrap_pid=$!; (cd admin && yarn && yarn dev) & admin_pid=$!; trap 'kill $gateway_pid $bootstrap_pid $admin_pid 2>/dev/null' INT TERM EXIT; wait $gateway_pid $bootstrap_pid $admin_pid; }", + "use_new_terminal": true, + "allow_concurrent_runs": false, + "reveal": "always", + "shell": { + "with_arguments": { + "program": "/bin/zsh", + "args": ["-c"], + }, + }, + }, { "label": "Run Infrastructure", "command": "docker compose up", @@ -69,9 +82,9 @@ "reveal": "always", }, { - "label": "Run Admin Next", + "label": "Run Admin", "command": "source ~/.nvm/nvm.sh && nvm use 22 && yarn && yarn dev", - "cwd": "$ZED_WORKTREE_ROOT/admin-next", + "cwd": "$ZED_WORKTREE_ROOT/admin", "use_new_terminal": true, "allow_concurrent_runs": false, "reveal": "always", diff --git a/2026-02-11.md b/2026-02-11.md deleted file mode 100644 index 6bc167091..000000000 --- a/2026-02-11.md +++ /dev/null @@ -1 +0,0 @@ -# 2026-02-11 diff --git a/AGENTS.md b/AGENTS.md index 32c082435..b33123f5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,15 @@ Modular e-commerce platform built with NestJS microservices and GraphQL Federation. +Project is at 25% readiness. No stage/production data or users does exist. +Backward compatibility and backfilling are strictly forbidden. + ## Knowledge Base -@knowledge/CLAUDE.md +@knowledge/AGENTS.md + +Project READ ONLY rules and architecture patterns are stored in the knowledge base vault under `@knowledge/vault/`. +Before creating implementation plans or making code/documentation changes, review and align the work with the relevant documents from the knowledge base. ## Architecture @@ -12,7 +18,6 @@ Modular e-commerce platform built with NestJS microservices and GraphQL Federati - **API:** GraphQL Federation (Apollo Server + Hive Gateway + Mesh Compose) - **Database:** PostgreSQL + Drizzle ORM + Knex (orders, checkout, delivery, pricing, apps) - **Workflows:** DBOS SDK -- **Event Sourcing:** Emmett + Pongo + Dumbo (orders, checkout) - **Storage:** MinIO - **Cache:** Upstash Redis, cache-manager + Keyv - **Auth:** better-auth @@ -27,12 +32,11 @@ Modular e-commerce platform built with NestJS microservices and GraphQL Federati | --------- | ------------------------------------------------------- | | apps | Application management and configuration | | bootstrap | Service orchestrator and entrypoint | -| catalog | Products, variants, categories, tags, options, features | +| catalog | Products, variants, categories, tags, options, features, stock, warehouses | | checkout | Shopping cart, checkout flow, line items | | delivery | Shipping providers (Nova Poshta, Meest) | | events | Event persistence and dispatch | | iam | Identity and access management | -| inventory | Stock levels and inventory management | | media | File storage and media assets | | orders | Order processing and fulfillment | | payments | Payment providers integration | @@ -41,8 +45,12 @@ Modular e-commerce platform built with NestJS microservices and GraphQL Federati ## Development -Use `shopana-cli` MCP tools for all development tasks (dev, build, migrate, codegen, schema, test). +Use `shopana-cli` MCP tools for all development tasks (dev, build, migrate, codegen, schema, test, e2e, playwright). + +## Admin Frontend + +The Admin frontend lives in `admin/`. ## Testing -Playwright e2e tests. See @e2e/CLAUDE.md +Playwright e2e tests. See @e2e/AGENTS.md diff --git a/README.md b/README.md index 802f5a665..238c4cc41 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,18 @@ Shopana is a headless commerce backend providing a unified GraphQL API through f | ------------ | -------------------------------------------------- | | `apps` | Application management and configuration | | `bootstrap` | Service orchestrator and entrypoint | +| `catalog` | Products, variants, collections, stock, and warehouses | | `checkout` | Shopping cart, checkout flow, and line items | | `delivery` | Shipping providers integration (Nova Poshta, Meest)| -| `inventory` | Products, variants, collections, and stock | +| `events` | Event persistence and dispatch | +| `iam` | Identity and access management | | `media` | File storage and media assets management | | `orders` | Order processing and fulfillment | | `payments` | Payment providers integration | | `pricing` | Price calculations and promotions | | `project` | Project settings, locales, and currencies | -| `users` | User accounts and authentication (Casdoor) | +| `reviews` | Product reviews | +| `search` | Search functionality | ### Packages @@ -28,7 +31,6 @@ Shared libraries used across services: - `@shopana/cli` — Development CLI tooling - `@shopana/build-tools` — ESBuild configuration -- `@shopana/plugin-sdk` — Plugin development SDK - `@shopana/drizzle-query` — Drizzle ORM query helpers with Relay pagination - `@shopana/shared-kernel` — Domain primitives and utilities - `@shopana/platform-api` — Platform API client @@ -93,7 +95,7 @@ Start the development environment with hot-reload. ```bash yarn shopana dev # Start all services (orchestrator) -yarn shopana dev -s inventory # Start specific service only +yarn shopana dev -s catalog # Start specific service only ``` **Options:** @@ -125,7 +127,7 @@ Run Drizzle database migrations. ```bash yarn shopana migrate # Migrate all services -yarn shopana migrate -s inventory # Migrate specific service +yarn shopana migrate -s catalog # Migrate specific service ``` **Options:** @@ -171,14 +173,14 @@ yarn shopana schema build # Export + compose (full schema build) services/ ├── packages/ # Shared libraries │ ├── cli/ # Shopana CLI -│ ├── plugin-sdk/ # Plugin development SDK +│ ├── app-sdk/ # Hosted Apps SDK │ ├── drizzle-query/ # Query builder with Relay │ └── shared-*/ # Shared utilities ├── services/ # Microservices │ ├── apps/ │ ├── bootstrap/ +│ ├── catalog/ │ ├── checkout/ -│ ├── inventory/ │ ├── orders/ │ └── ... ├── federation/ # GraphQL federation config diff --git a/admin/.gitignore b/admin/.gitignore index 37f5aa369..ac748cb6b 100644 --- a/admin/.gitignore +++ b/admin/.gitignore @@ -15,6 +15,7 @@ # next.js /.next/ +/.next-e2e-*/ /out/ # production diff --git a/admin/docs/admin-api-integration-dry-architecture-smells.md b/admin/docs/admin-api-integration-dry-architecture-smells.md new file mode 100644 index 000000000..5e735d2bc --- /dev/null +++ b/admin/docs/admin-api-integration-dry-architecture-smells.md @@ -0,0 +1,377 @@ +# Анализ admin API-интеграции products/categories/tags: DRY и architecture smells + +Дата анализа: 2026-06-24. + +## Статус исправлений + +Пункты 1-5 из этого отчета исправлены в admin-коде: + +- Пункт 1: feature-local `graphql/index.ts` для products/categories/tags больше не реэкспортит `operation-types`; `ApiCategoryCategoriesMetaInput` используется из generated `@/graphql/types`. +- Пункт 2: общие `UserErrorFields`, `FileFields`, `RichTextFields` вынесены в shared fragments; одинаковые tag/category fragments сведены к одному источнику; product details/mutation получили общий base fragment; product list variant payload сужен до реально используемых `price.amountMinor` и `inventoryItem.totalAvailable`. +- Пункт 3: повторяющийся `useQuery`/`previousData`/Relay connection unwrap вынесен в `useRelayConnectionQuery`; `useProducts`, `useCategories`, `useTags`, `useCategoryProducts` остались тонкими module-specific wrappers. +- Пункт 4: повторяющаяся API/table pagination orchestration для products/categories/tags вынесена в `useInventoryRelayListPage`; страницы оставляют у себя domain-specific columns, selection, actions и modal callbacks. +- Пункт 5: string/int/date range filter transformer helpers вынесены в общий `layouts/filters` layer; category/tag page configs теперь только связывают API field name с generic transformer. + +Ограничение остается на уровне API-контракта: `ProductListFields` все еще вынужден запрашивать `variants(first: 100)` для расчета min/max price и stock, пока API не отдаст dedicated aggregate fields для списка товаров. + +## Область анализа + +Проверены страницы, модалки, GraphQL documents, hooks и mappers для: + +- `admin/src/domains/inventory/products` +- `admin/src/domains/inventory/categories` +- `admin/src/domains/inventory/tags` +- общие pickers в `admin/src/shared/components/entity-picker-modal` + +Критерии сверялись с `knowledge/vault/patterns/admin-graphql-layer.md` и `knowledge/vault/patterns/currency-handling.md`. + +## Краткий вывод + +Текущая структура уже частично идет в правильную сторону: у products/categories/tags есть модульные `graphql/`, `hooks/`, `mappers/`, `page/`, `modals/`, а UI во многих местах принимает generated API types напрямую из `@/graphql/types`. + +Основная проблема не в отсутствии модулей, а в том, что границы между ними размыты: + +- API write orchestration живет одновременно в hooks, details components, modal orchestration hooks и callback payloads. +- Есть повторяющиеся GraphQL fragments, operation typings, connection hooks, filter transformers, mutation wrappers и form/API mappers. +- Cache/refetch strategy локальная и несогласованная: часть hooks рефетчит hard-coded queries, часть страниц вызывает `refetch()` из modal callbacks, часть details sections делает ручной refresh после локального состояния. +- Product-category и product-tag связи реализованы несколькими конкурирующими способами. + +## Что соответствует паттернам + +- Модули имеют целевую папочную структуру из `admin-graphql-layer`: `graphql`, `hooks`, `mappers`, `page`, `modals`. +- Query hooks в целом скрывают вложенные пути вида `data.catalogQuery.products` и возвращают `products/categories/tags`, `totalCount`, `pageInfo`, `loading`, `error`. +- Мутационные hooks возвращают `userErrors` и не бросают API validation errors наружу как исключения. +- Products list рендерит цены через `useDefaultCurrency()` и `formatPrice(value, defaultCurrency)`, то есть не использует per-record `price.currency` как display source. + +## Findings + +### 1. Локальный GraphQL barrel становится вторым источником API типов + +Severity: высокая. + +Статус: исправлено. + +До исправления во всех трех модулях `graphql/index.ts` реэкспортировал `operation-types`: + +- `products/graphql/index.ts:1-4` +- `categories/graphql/index.ts:1-4` +- `tags/graphql/index.ts:1-4` + +Это провоцирует импорты operation/API typings из feature-local `../graphql`, хотя правило `admin-graphql-layer` говорит импортировать generated API types напрямую из `@/graphql/types`. + +Самый явный случай drift был в `categories/graphql/operation-types.ts:39-54`: файл заново объявлял `ApiCategoryHierarchyScopeInput`, `ApiCategoryProductsScopeInput`, `ApiCategoryCategoriesMetaInput`, хотя generated type уже есть в `admin/src/graphql/types.ts:1359-1362`. Дальше этот локальный `ApiCategoryCategoriesMetaInput` импортировался из feature barrel в: + +- `shared/components/entity-picker-modal/category-picker-modal.tsx` +- `shared/components/entity-picker-modal/configs/category-picker-config.ts` +- `products/components/product-details-card/sections/categories-section.tsx` +- `categories/components/category-details-card/hooks/use-category-modals.ts` + +Риск был в том, что generated schema меняется, а локальная копия типа остается прежней. Также имя с префиксом `Api` создавало ложное ощущение, что это generated contract. + +Выполнено: + +- `graphql/index.ts` barrel оставлен только для operation documents. +- Локальные `ApiCategory*MetaInput` declarations удалены. +- `ApiCategoryCategoriesMetaInput` импортируется из `@/graphql/types`. +- `operation-types.ts` содержит operation response/variables на базе generated types. + +### 2. GraphQL fragments дублируются и местами слишком широкие + +Severity: средняя/высокая. + +Статус: исправлено в части DRY и сужения list payload; API-level aggregate для полного удаления `variants(first: 100)` еще нужен. + +До исправления были повторения: + +- `UserErrorFields` есть в products (`products/graphql/fragments.ts:3-9`), categories (`categories/graphql/fragments.ts:3-9`) и tags как `TagUserErrorFields` (`tags/graphql/fragments.ts:3-9`). +- `FileFields` в products (`products/graphql/fragments.ts:19-60`) и `CategoryFileFields` в categories (`categories/graphql/fragments.ts:11-52`) практически одинаковые. +- `TagListFields`, `TagDetailsFields`, `TagMutationResultFields` содержат одинаковый набор полей (`tags/graphql/fragments.ts:11-39`). + +Отдельный performance smell: `ProductListFields` для list/table тянул `variants(first: 100)` с широкими price и inventory (`products/graphql/fragments.ts:273-292`). Таблица затем считает min/max price и stock на клиенте. Это связывает список товаров с деталями вариантов, создает скрытый лимит `100` и переносит агрегирование из API в UI. + +Выполнено: + +- Стабильные shared fragments (`UserErrorFields`, `FileFields`, `RichTextFields`) вынесены в общий admin GraphQL layer. +- Для tags/category одинаковые fragments сведены к одному источнику. +- Для products details/mutation выделен общий base fragment. +- Для products list variant payload сужен до `price.amountMinor` и `inventoryItem.totalAvailable`. + +Осталось: + +- Для полного исправления performance smell на API уровне нужны aggregate fields для min/max price и total stock, чтобы убрать `variants(first: 100)` из list query. + +### 3. Connection query hooks повторяют один и тот же шаблон + +Severity: средняя. + +Статус: исправлено. + +`useProducts`, `useCategories`, `useTags`, `useCategoryProducts` повторяют один контракт: + +- `useQuery` +- `data ?? previousData` +- `connection?.edges.map(edge => edge.node)` +- `totalCount` +- `pageInfo` +- `refetch: () => refetch()` + +Примеры: + +- `products/hooks/use-products.ts` +- `categories/hooks/use-categories.ts` +- `tags/hooks/use-tags.ts` +- `categories/hooks/use-category-products.ts` + +Риск: каждое изменение в pagination behavior, fetch policy, previousData policy, error normalization придется повторять вручную. Уже видно расхождение: `useCategories` имеет `fetchPolicy` option, остальные list hooks нет. + +Выполнено: + +- Добавлен общий helper уровня admin GraphQL layer: `admin/src/graphql/hooks/use-relay-connection-query.ts`. +- Helper централизует `useQuery`, `data ?? previousData`, `connection.edges.map(edge => edge.node)`, `totalCount`, `pageInfo`, `error` и `refetch`. +- `useProducts`, `useCategories`, `useTags`, `useCategoryProducts` теперь описывают только operation document, variables, fetch policy и путь к connection. +- Module-specific return names (`products`, `categories`, `tags`) сохранены как wrappers над `nodes`. + +### 4. Pages повторяют API/table shell + +Severity: средняя. + +Статус: исправлено. + +Products, categories и tags pages повторяют одинаковую схему: + +- `usePageConfig` +- `build*QueryVariables(pageConfig)` +- list hook +- `goToNextPage/goToPrevPage` +- `AgGridReact` +- `CursorPagination` +- `DataLayout.Toolbar` с `FilterWidget` + +Примеры: + +- products page: `products/page/page.tsx:147-260` +- categories page: `categories/page/page.tsx:168-276` +- tags page: `tags/page/page.tsx:86-143` и `tags/page/page.tsx:185-260` + +Это не только UI-duplication. Query variables, pagination cursors и refetch callbacks являются частью API-интеграции, поэтому повторение повышает риск несовместимых list behaviors. + +Выполнено: + +- Добавлен `admin/src/domains/inventory/hooks/use-inventory-relay-list-page.ts`. +- Hook централизует `usePageConfig`, `build*QueryVariables(pageConfig)`, list query hook вызов, `handleNextPage`, `handlePrevPage`, `pageInfo`, `totalCount`, `loading`, `error`, `refetch`. +- Products/categories/tags pages оставляют у себя domain-specific `columns`, `filterSchema`, `sortFieldMapping`, `buildSearchCondition`, row actions, selection panels и modal callbacks. +- Общий компонент `InventoryRelayTablePage` не вводился, потому selection/bulk actions и row behavior у страниц уже различаются; hook убирает именно повторяющуюся API/table pagination orchestration. + +### 5. Filter transformers для categories/tags почти полностью скопированы + +Severity: средняя. + +Статус: исправлено. + +`categories/page/page-config.ts:39-190` и `tags/page/page-config.ts:39-187` почти одинаково реализуют: + +- `isEmptyFilterValue` +- `getFirstFilterValue` +- `toDateTimeInput` +- `buildStringFilter` +- `buildIntFilter` +- `buildDateTimeFilter` +- factories для string/int/date transformers + +Риск: баг в operator mapping или DateRange behavior придется исправлять в нескольких местах. Уже есть небольшое структурное расхождение в `buildDateTimeFilter`: categories оборачивает логику в `if`, tags делает early return. + +Выполнено: + +- Добавлены generic helpers в `admin/src/layouts/filters/utils/graphql-filter-transformers.ts`. +- Из `categories/page/page-config.ts` и `tags/page/page-config.ts` удалены локальные `isEmptyFilterValue`, `getFirstFilterValue`, `toDateTimeInput`, `buildStringFilter`, `buildIntFilter`, `buildDateTimeFilter`. +- Page configs теперь только связывают API field name с `createGraphqlStringFilterTransformer`, `createGraphqlIntFilterTransformer`, `createGraphqlDateTimeRangeFilterTransformer`. + +### 6. Product-category relation mutations размножены четырьмя hooks + +Severity: высокая. + +`useAddCategoryProduct`, `useRemoveCategoryProduct`, `useSetCategoryProductPrimary`, `useMoveCategoryProduct` почти идентичны: + +- один `PRODUCT_CATEGORY_UPDATE_MUTATION` +- один result shape +- один `refetchQueries` массив +- одна catch-normalization +- отличается только `ProductCategoryOperationAction` и optional move args + +Примеры: + +- add: `categories/hooks/use-add-category-product.ts:40-90` +- remove: `categories/hooks/use-remove-category-product.ts:40-90` +- set primary: `categories/hooks/use-set-category-product-primary.ts:40-90` +- move: `categories/hooks/use-move-category-product.ts:42-96` + +Дополнительно contract smell: hooks возвращают `category: null`, хотя mutation фактически вызывает `productUpdate` и возвращает product payload. Возвращаемый тип `CategoryProductMutationResult` не соответствует операции. + +Рекомендация: + +- Заменить на один hook `useUpdateProductCategories`/`useProductCategoryOperations`. +- Принимать массив operations, а convenience methods (`add/remove/setPrimary/move`) делать тонкими wrappers. +- Возвращать честный payload: `product`, `operationResults`, `userErrors/errors`. +- Вынести refetch/cache policy в один helper. + +### 7. API write orchestration живет в UI components и modal orchestration hooks + +Severity: высокая. + +Паттерн говорит: hooks владеют Apollo calls, mappers конвертируют form/editor state в API input, components показывают UI и используют normalized result. Сейчас часть use-case логики находится прямо в UI layer: + +- `ProductInfoHeader` сам собирает `ProductUpdateInput` для title/status/archive и показывает API errors (`products/components/product-info-header/product-info-header.tsx:91-153`). +- `ProductContentTabs` строит `ApiProductContentInput`, парсит EditorJS json и вызывает `updateProduct` (`products/components/product-content-tabs/product-content-tabs.tsx:33-118`). +- `useProductModals` собирает media, SEO, variants operations и вручную делает `client.refetchQueries` (`products/components/product-details-card/hooks/use-product-modals.ts:88-330`). +- `useCategoryModals` управляет status/archive/hierarchy/product assignment и циклом мутаций (`categories/components/category-details-card/hooks/use-category-modals.ts:71-305`). +- Category edit modals сами вызывают `useUpdateCategory` и маппят errors. + +Это создает несколько разных "application services" внутри UI, вместо одного API layer per use case. + +Рекомендация: + +- Ввести use-case hooks: `useUpdateProductIdentity`, `useUpdateProductContent`, `useUpdateProductMedia`, `useUpdateProductSeo`, `useUpdateProductTags`, `useUpdateProductVariants`, `useUpdateCategoryHierarchy`, `useAssignCategoryProducts`. +- Вынести input сборку из components в mappers. +- Модалки должны отдавать form values, а не получать callback, внутри которого parent собирает API operation. + +### 8. Cache/refetch policy несогласованна + +Severity: высокая. + +Примеры: + +- `useCreateProduct` и `useDeleteProduct` refetch-ят `PRODUCTS_QUERY` с hard-coded variables `{ first: 20, after: null, last: null, before: null }` (`products/hooks/use-create-product.ts:78-84`, `products/hooks/use-delete-product.ts:35-40`). Это не учитывает текущий поиск, фильтры, сортировку и page size. +- `useUpdateCategory` всегда refetch-ит `[CATEGORY_DETAILS_QUERY, CATEGORIES_QUERY]` без привязки к переменным текущей страницы (`categories/hooks/use-update-category.ts:53-60`). +- Tags create/update refetch-ят bare documents (`tags/hooks/use-create-tag.ts:29-32`, `tags/hooks/use-update-tag.ts:33-36`), а pages дополнительно вызывают `refetch()` через `onCreated`. +- Product relation sections после hook-level refetch еще вызывают `onProductRefresh` вручную (`products/components/product-details-card/sections/categories-section.tsx:86-117`, `products/components/product-details-card/sections/tags-section.tsx:48-94`). +- Variants save refresh вручную собирает `Promise.allSettled` и `client.refetchQueries({ include })` (`use-product-modals.ts:204-237`). + +Риск: stale UI на отфильтрованных/отсортированных страницах, лишние network calls, неочевидная свежесть данных. + +Рекомендация: + +- Для каждого mutation hook явно описать freshness contract: `cache.modify`, `refetchQueries` с caller-provided variables, или caller callback, но не все сразу. +- Для list mutations принимать `listVariables?: ProductsQueryVariables/CategoriesQueryVariables/TagsQueryVariables`. +- Убрать page-level `onCreated: refetch` там, где hook уже делает refetch, или наоборот оставить refetch только у caller. +- Для relation mutations централизовать invalidation списка продукта, details продукта, category products и counters. + +### 9. Product-tag связь имеет две конкурирующие реализации + +Severity: средняя/высокая. + +Есть API-backed `TagsSection`, который работает через `useTagPicker` и `useUpdateProduct` (`products/components/product-details-card/sections/tags-section.tsx:56-147`). + +Параллельно есть `EditTagsModal`, которая импортирует `mockApiTags` (`products/modals/edit-tags-modal/edit-tags-modal.tsx`) и `useProductModals` явно открывает ее с `message.info("Tag assignment is not API-backed yet")` (`use-product-modals.ts:120-129`). + +Риск: два UX для одного use case расходятся по поведению, один из них mock-only. Следующий разработчик может подключить старую modal path вместо API-backed section. + +Рекомендация: + +- Удалить или deprecated-mark `EditTagsModal`, если `TagsSection` является целевым API-backed flow. +- Либо подключить `EditTagsModal` к тому же `useUpdateProductTags` hook и `useTags`/`useTagPicker` data source. +- Оставить один canonical product-tag write path. + +### 10. Mappers/forms дублируют slug, rich text, media и user error mapping + +Severity: средняя. + +Повторения: + +- Product/category/tag create schemas имеют одинаковый `handleSchema`. +- Product/category/tag create sections вручную реализуют auto-slug behavior через `slugify`. +- `prepareDescription` в product create (`products/mappers/product-create.mapper.ts:22-36`) и `prepareRichText` в category create (`categories/mappers/category-create.mapper.ts:18-32`) делают одинаковую конвертацию EditorJS -> `RichTextInput`. +- `prepareMediaFileIds` есть в product create (`products/mappers/product-create.mapper.ts:38-44`) и category create (`categories/mappers/category-create.mapper.ts:34-40`). +- `mapProductUserErrorsToFormErrors` и `mapCategoryUserErrorsToFormErrors` имеют одинаковую механику alias lookup. +- `formatOperationType` повторяется в product/category error mappers. +- Product option/feature editor error mappers повторяют `formatFieldPath`, `pushError` и mapping по input index. + +Рекомендация: + +- Вынести `handleSchema`, `useAutoSlugField`/`AutoSlugIdentityFields`, `editorOutputToRichTextInput`, `mediaFilesToFileIds`, `mapUserErrorsByAliases`, `normalizeOperationResults`. +- Для options/features сделать общий `mapIndexedUserErrorsToEditorErrors`. + +### 11. Modal payload callbacks смешивают UI state и API application logic + +Severity: средняя. + +Product modal payloads активно передают `onSave` с async API логикой: + +- `IProductEditTitleModalPayload` +- `IProductEditDescriptionModalPayload` +- `IEditMediaModalPayload` +- `IEditSeoModalPayload` +- `IEditVariantsModalPayload` +- `IEditTagsModalPayload` + +Category payload interfaces тоже объявляют `onSave`, но конкретные category edit modals сейчас чаще игнорируют эти callbacks и вызывают `useUpdateCategory` напрямую. + +Риск: нет единого ownership. Для products API orchestration часто в parent hook/component; для categories часто внутри modal; для tags внутри modal. Это усложняет повторное использование и тестирование. + +Рекомендация: + +- Выбрать один pattern: + - либо modal is form-only и parent/use-case hook выполняет save; + - либо modal владеет save через use-case hook. +- Для API-backed модулей предпочтительнее второй вариант: modal получает entity/form initial values, вызывает use-case hook и возвращает `onSaved`. + +### 12. Product variants используют ручной Apollo client state рядом с query hooks + +Severity: средняя. + +`useProductVariantsConnection` вручную реализует loading/error/local connection state через `useApolloClient().query`, `useEffect`, `loadMore`, `appendVariantConnection`. Рядом `useProduct` уже грузит variants page как часть product details, а `useProductVariantsLoader` отдельно загружает все страницы по `first: 100`. + +Риск: три read path для variants могут расходиться по cache behavior, pagination assumptions и selected fields. + +Рекомендация: + +- Свести variants read path к одному hook contract. +- Если нужен infinite/load-all режим, строить его вокруг Apollo `fetchMore`/typed query hook, а не отдельной локальной модели, либо явно оформить как низкоуровневый data service. + +### 13. API-backed product details все еще смешан с mock supplemental data + +Severity: средняя. + +`ProductModal` получает product из API, но передает `productDetailsMockData` в `ProductDetailsCard`. Кроме того, часть flows помечена как mock-only: bulk editor action disabled с tooltip, AI writer использует mock generator, bundles/reviews sections завязаны на mock types/data. + +Это не прямое нарушение DRY, но architecture smell для API-интеграции: API-backed details screen выглядит завершенным, хотя часть секций не имеет API contract. + +Рекомендация: + +- Явно отделить API-backed sections от mock-only sections в naming и docs. +- Для mock-only sections не держать их в product details API card как равноправные API sections без явного feature flag/empty state. + +## Приоритетный план устранения + +1. Зафиксировать GraphQL type ownership: + - убрать локальный `ApiCategoryCategoriesMetaInput`; + - запретить импорт generated-like типов из feature `graphql` barrels; + - оставить generated API types только в `@/graphql/types`. + +2. Вынести shared GraphQL fragments и common connection hook: + - `UserErrorFields`, `FileFields`, `RichTextFields`; + - `useRelayConnectionQuery`. + +3. Нормализовать mutation freshness: + - убрать hard-coded `first: 20`; + - передавать текущие list variables или использовать `cache.modify`; + - выбрать один источник refetch для create/update/delete. + +4. Свести product-category и product-tag связи к use-case hooks: + - `useUpdateProductCategories`; + - `useUpdateProductTags`; + - один canonical modal/section flow на связь. + +5. Вынести повторяющиеся form/API utilities: + - slug/handle schema и auto-generation; + - EditorJS -> RichTextInput; + - media files -> file IDs; + - user error aliases и operation result normalization; + - generic filter transformers для string/int/date. + +6. После этого уже стоит уплотнять pages: + - общий table/list shell; + - domain modules оставляют только columns/filter/sort/search config. + +## Проверки + +Тесты и `tsc` не запускались согласно project instructions. Build не запускался, потому что анализ не меняет runtime-код. diff --git a/admin/docs/admin-apps-sdk-plan.md b/admin/docs/admin-apps-sdk-plan.md new file mode 100644 index 000000000..226f9c5b3 --- /dev/null +++ b/admin/docs/admin-apps-sdk-plan.md @@ -0,0 +1,1437 @@ +# Admin Apps domain и внутренний Apps SDK — архитектурный план + +## Статус + +- Статус документа: proposed. +- Версия решения: v1. +- Область: Admin UI, `services/apps`, backend hosted Apps из `apps/*`, + hosted App UI из `admin/src/domains/apps/`, App build pipeline. +- Admin runtime: Next.js App Router + Turbopack host. +- Remote runtime: client-only Module Federation modules. +- UI stack: React, Ant Design, `antd-style`, `@ant-design/cssinjs`, AG Grid. +- Backward compatibility и backfill не предусматриваются. + +Связанный backend-план: + +```text +docs/apps-platform-architecture.ru.md +``` + +## 1. Резюме решения + +В первой версии Apps SDK не создаётся как отдельный workspace/npm package. +Он реализуется непосредственно внутри Admin: + +```text +admin/src/domains/apps/sdk +``` + +Apps становятся отдельным Admin domain: + +```text +admin/src/domains/apps +``` + +Этот domain владеет: + +- страницами управления установленными и доступными Apps; +- GraphQL operations и hooks для Apps control plane; +- синхронизацией установленных Apps текущего store; +- Module Federation runtime registry; +- загрузкой App pages, modals и extension contributions; +- внутренним Apps SDK; +- App-scoped adapters над modal stack, navigation, GraphQL и UI primitives; +- runtime error boundaries и telemetry. + +Текущий модуль: + +```text +admin/src/domains/system/apps +``` + +переносится в новый domain и больше не принадлежит `system`. + +URL страницы управления Apps может остаться прежним: + +```text +/:orgName/:storeName/system/integrations/apps +``` + +Но route registration, page, hooks и GraphQL принадлежат +`admin/src/domains/apps`, а не `admin/src/domains/system`. + +Установленное App получает собственную runtime page: + +```text +/:orgName/:storeName/apps/:appCode{/*appPath} +``` + +Admin остаётся владельцем Next routing, React tree, Ant Design providers, +theme, CSS-in-JS cache, modal stack, AG Grid configuration и security +boundary. + +## 2. Цели v1 + +Установленное App должно уметь: + +1. рендерить client component на своей странице; +2. поддерживать deep links внутри собственного `appPath`; +3. открывать свои modals в общем Admin modal stack; +4. открывать разрешённые core modals, например Product details и Product + picker; +5. участвовать в стандартных Admin pages через typed extension points; +6. использовать Ant Design, `antd-style`, CSS-in-JS tokens и theme текущего + Admin; +7. использовать AG Grid через host-owned wrapper; +8. использовать Admin navigation, notifications и GraphQL transport без + доступа к auth token; +9. корректно отключаться при store switch, suspend и uninstall; +10. загружать remote JS только при первом фактическом использовании. + +## 3. Не цели v1 + +В первую версию не входят: + +- отдельный опубликованный package `@shopana/admin-app-sdk`; +- независимый от platform build frontend App release lifecycle; +- загрузка произвольных npm packages или исходных файлов в runtime; +- SSR и React Server Components из remote Apps; +- собственный Next.js runtime или router внутри App; +- отдельный React root для App; +- iframe sandbox для недоверенного third-party code; +- удалённая загрузка backend-кода; +- доступ App к auth token и secrets; +- прямой доступ App к внутренним Admin domains; +- возможность заменить core layout, theme providers или modal renderer; +- регистрация произвольных core modal IDs; +- произвольные UI slots, отсутствующие в Admin extension point catalog; +- полная runtime-выгрузка уже загруженного JavaScript без browser reload. + +Module Federation runtime предназначен только для trusted Apps, которые входят +в platform repository и проходят build validation. + +## 4. Связь с backend Apps architecture + +Backend App и Admin remote являются двумя surfaces одного hosted App: + +```text +Shopana App +├─ App manifest +├─ backend definition +├─ backend runtime hosted by services/apps +├─ optional GraphQL subgraph +└─ optional Admin UI artifact +``` + +Используется единая идентичность: + +```text +appCode + version +``` + +И единая store-scoped installation: + +```text +AppInstallation +├─ organizationId +├─ storeId +├─ appCode +├─ installedVersion +├─ status +├─ manifestHash +├─ grantedScopes +└─ configuration +``` + +Отдельные `AppRelease`, `InstalledUiApp` или frontend release pinning в v1 не +создаются. + +### 4.1 Два execution plane + +Backend plane: + +- App package статически входит в bundled registry `services/apps`; +- один process-wide App instance обслуживает installations всех stores; +- App GraphQL schema входит в supergraph во время platform build; +- resolvers проверяют installation, store, scopes и user RBAC. + +Admin plane: + +- browser получает UI descriptor только для текущего store; +- remote JS загружается через Module Federation лениво; +- App component рендерится внутри существующего React tree; +- UI registry очищается при смене installation context. + +Backend static registry и frontend runtime loading не конфликтуют. Динамической +является активация App для store и загрузка browser artifact, но не состав +platform release. + +### 4.2 Backend slots и Admin extension points + +Это разные контракты: + +```text +Backend capability slot + -> выбирает App action для business operation + +Admin extension point + -> выбирает UI contribution для места на core page +``` + +Admin extension points не сохраняются в backend `platform.slots` и не +используют capability routing. + +## 5. Target structure Admin domain + +```text +admin/src/domains/apps/ +├─ index.ts +├─ register.tsx +├─ management/ +│ ├─ page/ +│ ├─ components/ +│ ├─ graphql/ +│ │ ├─ fragments.ts +│ │ ├─ queries.ts +│ │ ├─ mutations.ts +│ │ ├─ operation-types.ts +│ │ └─ index.ts +│ ├─ hooks/ +│ └─ mappers/ +├─ runtime/ +│ ├─ admin-apps-host-provider.tsx +│ ├─ installed-apps-runtime-sync.tsx +│ ├─ app-runtime-page.tsx +│ ├─ app-runtime-boundary.tsx +│ ├─ app-runtime-scope.ts +│ ├─ descriptor-schema.ts +│ ├─ federation/ +│ │ ├─ runtime.ts +│ │ ├─ register-remote.ts +│ │ ├─ load-remote-module.ts +│ │ └─ errors.ts +│ ├─ registry/ +│ │ ├─ app-registry.ts +│ │ ├─ modal-registry.ts +│ │ ├─ extension-registry.ts +│ │ └─ navigation-registry.ts +│ └─ telemetry/ +├─ sdk/ +│ ├─ index.ts +│ ├─ contracts.ts +│ ├─ create-app-sdk.ts +│ ├─ modal-api.ts +│ ├─ navigation-api.ts +│ ├─ graphql-api.ts +│ ├─ notifications-api.ts +│ ├─ extension-points.ts +│ ├─ core-modals.ts +│ └─ ui/ +│ ├─ admin-data-grid.tsx +│ ├─ admin-app-page.tsx +│ ├─ admin-modal-layout.tsx +│ └─ types.ts +├─ / +│ ├─ module-federation.config.ts +│ └─ src/ +│ ├─ page.tsx +│ ├─ modals/ +│ └─ extensions/ +└─ test-support/ +``` + +Правила границ: + +- другие domains импортируют только `@/domains/apps`; +- internal files `runtime/*` и `sdk/*` не импортируются напрямую; +- `apps/index.ts` экспортирует только host components и extension point + contracts, необходимые core Admin; +- hosted App UI импортирует из Apps SDK только type-only contract alias и не + импортирует `runtime/*` или другие internal Admin domains; +- core Admin не импортирует source конкретного hosted App напрямую; +- public SDK contracts не содержат types из внутренних Admin domains; +- generated GraphQL API types импортируются напрямую из + `@/graphql/types`, согласно Admin GraphQL conventions. + +## 6. Как remote App использует внутренний SDK + +### 6.1 SDK передаётся через props + +Host создаёт scoped SDK object для конкретного App и installation: + +```ts +interface AdminAppSdk { + readonly app: { + code: string; + version: string; + installationId: string; + }; + + readonly context: { + organizationId: string; + storeId: string; + orgName: string; + storeName: string; + grantedScopes: readonly string[]; + }; + + readonly modals: AdminAppModalApi; + readonly navigation: AdminAppNavigationApi; + readonly graphql: AdminAppGraphqlApi; + readonly notifications: AdminAppNotificationsApi; + readonly ui: AdminAppUiApi; +} +``` + +Каждый remote entry получает SDK в props: + +```ts +interface AdminAppPageProps { + sdk: AdminAppSdk; + route: { + appPath: string; + searchParams: Readonly>; + }; +} +``` + +```tsx +export default function NovaPoshtaPage({ + sdk, + route, +}: AdminAppPageProps) { + return ( + + + + ); +} +``` + +Runtime SDK не включается в remote bundle и не загружается как отдельный +federated module. + +### 6.2 Type-only contract для producer + +Чтобы App author получал TypeScript autocomplete без отдельного package, App +producer использует type-only alias: + +```ts +import type { + AdminAppPageProps, + AdminAppModalProps, + AdminExtensionProps, +} from "@shopana/admin-app-sdk"; +``` + +В v1 `@shopana/admin-app-sdk` является compile-time alias на: + +```text +admin/src/domains/apps/sdk/index.ts +``` + +Это не workspace package и не runtime dependency. + +Обязательные ограничения: + +- разрешены только `import type` из alias; +- runtime imports из alias отклоняются App UI validator; +- contracts не импортируют Next, Zustand, Apollo internals или Admin domains; +- type imports полностью удаляются producer build; +- Module Federation remote получает настоящий SDK object от host. + +Такой контракт можно позднее перенести в отдельный package без изменения +remote component props. + +## 7. App source layout + +Backend source App остаётся в `apps/`: + +```text +apps/nova-poshta/ +├─ app.manifest.ts +├─ build.config.json +├─ package.json +└─ src/ + ├─ index.ts + ├─ NovaPoshtaApp.ts + ├─ actions/ + ├─ workflows/ + └─ graphql/ +``` + +Admin UI source этого App является частью Admin Apps domain: + +```text +admin/src/domains/apps/nova-poshta/ +├─ module-federation.config.ts +└─ src/ + ├─ page.tsx + ├─ modals/ + │ └─ shipment-create-modal.tsx + └─ extensions/ + ├─ order-shipment-panel.tsx + └─ create-shipment-action.tsx +``` + +Связь backend и UI определяется единым `appCode + version`. App generator +создаёт backend source и Admin UI source в двух соответствующих roots. + +Физическое расположение UI внутри Admin repository не означает статический +импорт в Admin host. Source конкретного App используется только отдельным App +UI producer build. Core Admin и runtime registries не импортируют его напрямую. + +Browser runtime никогда не получает filesystem source path: + +```text +admin/src/domains/apps/nova-poshta/src/page.tsx +``` + +Он получает только: + +```text +remote name + mf-manifest URL + exposed module name +``` + +## 8. Единый App manifest + +Admin UI становится optional section общего backend App manifest: + +```ts +interface AppManifest { + schemaVersion: 1; + code: string; + version: string; + displayName: string; + description?: string; + lifecycle: AppLifecycleManifest; + permissions: readonly string[]; + capabilities: readonly AppCapabilityManifest[]; + graphql?: AppGraphqlManifest; + adminUi?: AdminUiManifest; +} +``` + +```ts +interface AdminUiManifest { + sdkVersionRange: string; + remoteName: string; + page?: AdminAppPageDeclaration; + navigation?: readonly AdminAppNavigationDeclaration[]; + modals?: readonly AdminAppModalDeclaration[]; + extensions?: readonly AdminAppExtensionDeclaration[]; + requiredScopes?: readonly string[]; +} +``` + +Пример: + +```ts +adminUi: { + sdkVersionRange: "^1.0.0", + remoteName: "nova_poshta_admin", + page: { + module: "./Page", + defaultPath: "", + }, + navigation: [ + { + id: "nova-poshta", + label: "Nova Poshta", + path: "", + order: 100, + }, + ], + modals: [ + { + id: "shipment.create", + module: "./ShipmentCreateModal", + confirmOnDirtyClose: true, + requiredScopes: ["orders:read", "delivery:write"], + }, + ], + extensions: [ + { + id: "order-shipment-panel", + point: "orders.details.shipping.after", + module: "./OrderShipmentPanel", + priority: 100, + requiredScopes: ["orders:read"], + }, + ], +} +``` + +Manifest не содержит абсолютный CDN URL и не содержит source paths. + +## 9. Admin UI build artifact + +App UI build создаёт: + +```text +dist/admin/ +├─ mf-manifest.json +├─ remoteEntry.js +├─ assets/* +└─ admin-ui.artifact.json +``` + +```ts +interface AdminUiBuildArtifact { + appCode: string; + appVersion: string; + remoteName: string; + mfManifestPath: string; + contentHash: string; + exposes: readonly string[]; +} +``` + +Artifact metadata является частью bundled App registry текущего platform +release. + +Browser URL вычисляет `services/apps`: + +```text +manifestUrl = + config.services.apps.adminUi.publicAssetsBaseUrl + + mfManifestPath +``` + +`publicAssetsBaseUrl`: + +- является deployment config; +- одинаков для installations текущего deployment; +- не хранится в tenant configuration; +- не передаётся App author как runtime input. + +Assets публикуются по immutable path: + +```text +/apps////mf-manifest.json +``` + +## 10. Backend control-plane contract + +### 10.1 Persistence + +Используется существующая целевая backend-модель: + +```text +app_installations +app_installation_manifest_snapshots +app_installation_scopes +app_lifecycle_operations +``` + +Не добавляются: + +```text +app_releases +installed_ui_apps +ui_release_version +``` + +Canonical manifest snapshot автоматически включает `adminUi`. + +`installed_version` относится ко всему App: + +- backend runtime; +- GraphQL schema; +- Admin UI artifact. + +### 10.2 GraphQL descriptor + +Apps control plane добавляет read model: + +```graphql +type AdminAppRemote { + name: String! + manifestUrl: String! + contentHash: String! +} + +type AdminAppPage { + module: String! + defaultPath: String +} + +type AdminAppModal { + id: String! + module: String! + confirmOnDirtyClose: Boolean! + closeConfirmMessage: String + requiredScopes: [String!]! +} + +type AdminAppExtension { + id: String! + point: String! + module: String! + priority: Int! + requiredScopes: [String!]! + conditions: JSON +} + +type AdminAppUiDescriptor { + installationId: ID! + appCode: String! + displayName: String! + version: String! + sdkVersionRange: String! + remote: AdminAppRemote! + page: AdminAppPage + navigation: [AdminAppNavigationItem!]! + modals: [AdminAppModal!]! + extensions: [AdminAppExtension!]! + grantedScopes: [String!]! +} + +extend type AppsQuery { + adminUiApps: [AdminAppUiDescriptor!]! + adminUiApp(code: String!): AdminAppUiDescriptor +} +``` + +Resolver: + +1. использует verified organization/store context; +2. читает bundled App definition; +3. находит installation текущего store; +4. требует status `ACTIVE`; +5. проверяет App runtime health; +6. берёт manifest snapshot и granted scopes; +7. фильтрует недоступные contributions; +8. вычисляет immutable browser URL; +9. возвращает normalized descriptor. + +`SUSPENDED`, `UNINSTALLED`, incompatible и unhealthy App не получает активный +descriptor. + +Client-side filtering не является security boundary. App GraphQL resolvers и +core actions повторно проверяют installation, store, scopes и RBAC. + +## 11. Admin runtime lifecycle + +`AdminAppsHostProvider` монтируется один раз внутри существующих root providers: + +```text +ApolloProvider + Theme + AntdRegistry + ThemeContextProvider + antd-style ThemeProvider + Ant Design ConfigProvider + Ant Design App + AdminAppsHostProvider + ClientLayoutResolver + DomainLayout + ModalStack +``` + +Provider не создаёт второй React root или UI provider. + +### 11.1 Store activation + +При входе в store: + +1. `InstalledAppsRuntimeSync` выполняет `adminUiApps`; +2. валидирует descriptors через Zod schema; +3. проверяет `sdkVersionRange`; +4. регистрирует remote metadata без загрузки remote JS; +5. регистрирует lazy App modal definitions; +6. строит extension point index; +7. публикует navigation contributions; +8. создаёт owner scope для каждого descriptor. + +Owner key: + +```text +app:@: +``` + +### 11.2 Store switch, suspend и uninstall + +При деактивации owner scope: + +1. новые mounts блокируются; +2. App-owned modals закрываются; +3. unresolved modal promises завершаются typed cancellation result; +4. modal definitions удаляются; +5. extension contributions удаляются; +6. navigation contributions удаляются; +7. App SDK scope помечается disposed; +8. последующие вызовы старого SDK object отклоняются; +9. загруженный JavaScript может остаться в browser cache до reload. + +## 12. App page routing + +Admin регистрирует один локальный catch-all route: + +```text +/:orgName/:storeName/apps/:appCode{/*appPath} +``` + +Route render flow: + +```text +AppRuntimePage + -> resolve descriptor by appCode + -> verify ACTIVE installation and page declaration + -> resolve owner scope + -> register/load MF remote + -> load page module + -> create scoped AdminAppSdk + -> render +``` + +App не экспортирует Next page и не владеет browser history. + +Navigation выполняется через: + +```ts +sdk.navigation.openAppPath("shipments/123"); +sdk.navigation.openCorePath("/orders/123"); +sdk.navigation.replaceAppPath("settings"); +``` + +SDK: + +- добавляет canonical org/store prefix; +- запрещает выход на неизвестный external URL; +- валидирует core route allowlist; +- сохраняет App boundary. + +## 13. Module Federation runtime + +### 13.1 Host + +Admin host: + +- создаёт runtime instance один раз; +- регистрирует dynamic remotes по descriptor; +- lazy-loads exposed modules; +- применяет timeout/retry; +- проверяет allowed asset origin; +- логирует `appCode`, version, module и owner; +- кэширует успешную загрузку по immutable URL. + +### 13.2 Producer + +Remote producer: + +- имеет globally unique `remoteName`; +- exports обычные React components; +- не exports Next routes, server modules или `mount(element)`; +- не создаёт отдельный React root; +- не включает host-owned UI libraries; +- не импортирует runtime implementation внутреннего SDK; +- использует только manifest-declared exposes. + +### 13.3 Shared singletons + +Host владеет и предоставляет как singleton: + +- `react`; +- `react-dom`; +- `antd`; +- `@ant-design/icons`; +- `antd-style`; +- `@ant-design/cssinjs`; +- `@apollo/client`; +- `ag-grid-react`; +- `ag-grid-community`. + +Версии определяет Admin release. + +App producer не должен включать вторые копии этих dependencies в emitted +chunks. + +## 14. Theme и styling contract + +Remote component рендерится descendant существующих: + +- Ant Design `ConfigProvider`; +- Ant Design `App`; +- `antd-style` ThemeProvider; +- `@ant-design/cssinjs` cache; +- Admin theme context; +- ApolloProvider. + +Поэтому App может использовать: + +```tsx +import { App, Button, Form, Select } from "antd"; +import { createStyles } from "antd-style"; +``` + +И получает: + +- текущий light/dark algorithm; +- Admin design tokens; +- fonts; +- component overrides; +- CSS variables; +- `message`, `notification` и confirm APIs; +- правильные popup containers. + +App запрещено: + +- создавать собственный `ConfigProvider`; +- создавать собственный Ant Design `App`; +- создавать `ThemeProvider` или `StyleProvider`; +- менять global tokens; +- импортировать Admin global CSS; +- использовать global selectors; +- сбрасывать стили `html`, `body` или `#root`; +- полагаться на private Admin CSS class names. + +Допускаются: + +- Ant Design components; +- token-based `createStyles`; +- locally scoped CSS modules после validation; +- inline styles для вычисляемых layout values. + +## 15. Host-owned UI API + +SDK предоставляет небольшие stable wrappers: + +```ts +interface AdminAppUiApi { + AppPage: ComponentType; + ModalLayout: ComponentType; + DataGrid: ComponentType>; +} +``` + +### 15.1 AG Grid + +App использует: + +```tsx + +``` + +`AdminDataGrid`: + +- применяет текущую Admin AG Grid theme; +- использует host module registration; +- применяет locale и standard defaults; +- владеет license/runtime configuration; +- переключается между light/dark вместе с Admin. + +App не вызывает `ModuleRegistry.registerModules`. + +Raw AG Grid API может быть добавлен позднее как reviewed escape hatch. + +## 16. Modal stack integration + +Создавать второй modal system запрещено. + +Существующий Admin modal stack расширяется: + +- owner namespace; +- App modal definitions; +- typed result; +- owner-scoped cleanup; +- deterministic cancellation; +- public core modal adapters. + +### 16.1 App-owned modals + +Manifest: + +```ts +{ + id: "shipment.create", + module: "./ShipmentCreateModal", + confirmOnDirtyClose: true, +} +``` + +Runtime registry преобразует ID: + +```text +shipment.create + -> +app:nova-poshta@1.0.0::shipment.create +``` + +API: + +```ts +interface AdminAppModalApi { + openApp( + modalId: string, + payload: TPayload, + ): Promise>; + + openCore( + modal: TKey, + input: CoreModalContractMap[TKey]["input"], + ): Promise< + AdminModalResult + >; + + closeCurrent(result?: TResult): void; + setCurrentDirty(dirty: boolean): void; +} +``` + +Remote modal получает: + +```ts +interface AdminAppModalProps { + sdk: AdminAppSdk; + payload: TPayload; +} +``` + +App modal может открыть другую App modal или разрешённую core modal поверх +себя. + +### 16.2 Core modal allowlist + +SDK не раскрывает internal modal type strings. + +Начальный public catalog: + +```ts +interface CoreModalContractMap { + "catalog.product.details": { + input: { + productId: string; + mode?: "view" | "edit"; + }; + result: void; + }; + + "catalog.product.picker": { + input: { + multiple?: boolean; + selectedProductIds?: string[]; + }; + result: { + productIds: string[]; + }; + }; + + "catalog.variant.picker": { + input: { + productId?: string; + multiple?: boolean; + }; + result: { + variantIds: string[]; + }; + }; + + "media.file.picker": { + input: { + multiple?: boolean; + accept?: string[]; + }; + result: { + fileIds: string[]; + }; + }; +} +``` + +Core adapter: + +- преобразует stable public ID во внутренний modal type; +- валидирует input; +- проверяет required scopes; +- нормализует result; +- не раскрывает внутренние domain objects. + +## 17. GraphQL access + +Remote App может использовать GraphQL только через текущий Admin session и +store context. + +SDK предоставляет adapter: + +```ts +interface AdminAppGraphqlApi { + query( + document: TypedDocumentNode, + variables: TVariables, + ): Promise; + + mutate( + document: TypedDocumentNode, + variables: TVariables, + ): Promise; +} +``` + +Adapter: + +- использует host Apollo client; +- не возвращает auth token; +- не разрешает менять gateway URL; +- добавляет telemetry metadata; +- завершает request ошибкой после dispose App scope. + +Допускается прямое использование Apollo hooks только после фиксации shared +singleton contract. Предпочтительный v1 API — `sdk.graphql`. + +Backend App GraphQL resolver всё равно проверяет: + +```text +runtime READY +∩ installation ACTIVE +∩ current store +∩ manifest permissions +∩ granted scopes +∩ user RBAC +``` + +## 18. Admin extension points + +SDK задаёт закрытый typed catalog: + +```ts +interface AdminExtensionPointMap { + "orders.list.toolbar.actions": OrdersListToolbarContext; + "orders.list.row.actions": OrderRowActionsContext; + "orders.details.header.actions": OrderDetailsContext; + "orders.details.primary.after": OrderDetailsContext; + "orders.details.sidebar.after": OrderDetailsContext; + "orders.details.fulfillment.actions": FulfillmentContext; + "orders.details.fulfillment.after": FulfillmentContext; + "orders.details.shipping.after": OrderShippingContext; + "orders.details.payment.after": OrderPaymentContext; +} +``` + +Core page вставляет host component: + +```tsx + +``` + +App получает только stable context, а не internal React state, Zustand stores +или mutable domain services. + +Render flow: + +```text +AdminAppExtensionPoint + -> find active contributions for point + -> check owner scope and permissions + -> lazy-load declared module + -> create scoped SDK + -> render isolated contribution boundary +``` + +Ordering: + +```text +priority ASC +appCode ASC +contributionId ASC +``` + +Ошибка одного contribution не ломает core page и другие Apps. + +## 19. Apps management page + +Новый Apps domain владеет management UI: + +```text +admin/src/domains/apps/management +``` + +Он заменяет: + +```text +admin/src/domains/system/apps +``` + +Management UI работает с backend control plane: + +- available App definitions; +- installed Apps; +- installation status; +- required/granted scopes; +- install preview and consent; +- install/update/suspend/resume/uninstall operations; +- runtime and installation health. + +GraphQL code следует структуре: + +```text +management/graphql +management/hooks +management/mappers +management/page +``` + +Mocks удаляются после подключения control-plane GraphQL. API responses не +преобразуются в отдельные UI view models. + +Route registration переносится из: + +```text +admin/src/domains/system/register.tsx +``` + +в: + +```text +admin/src/domains/apps/register.tsx +``` + +При этом Apps management page может остаться визуально в группе +`System -> Integrations`. + +## 20. Security и failure isolation + +Module Federation remote работает с same-page privileges. Это не sandbox. + +Обязательные меры: + +- только bundled trusted Apps; +- allowlist asset origins; +- CSP для script и connect sources; +- immutable content-addressed paths; +- descriptor и artifact hash validation; +- Zod validation backend descriptor; +- SDK version compatibility check; +- server-side permission enforcement; +- no secrets и auth tokens в props; +- no arbitrary module URLs из user input; +- no arbitrary core modal IDs; +- no arbitrary navigation; +- no global CSS; +- page/modal/contribution error boundaries; +- load timeout и retry; +- kill switch через App installation/runtime state; +- telemetry с `appCode`, version, installationId, module и mount type. + +Remote failure не должна: + +- ломать Admin layout; +- ломать modal stack; +- удалять core content; +- оставлять unresolved modal promise; +- мешать загрузке других Apps. + +## 21. Build и validation + +### 21.1 Build order + +Целевой platform build: + +1. shared packages; +2. backend App packages; +3. App Admin UI producers из `admin/src/domains/apps/`; +4. generated bundled App registry и artifact metadata; +5. core services, включая `services/apps`; +6. GraphQL schema export/composition; +7. Admin host; +8. bootstrap. + +Admin host не содержит hardcoded remote URLs и не требует списка конкретных +Apps для runtime routing. + +### 21.2 App UI validator + +Validator проверяет: + +1. `adminUi` section общего App manifest; +2. уникальность page/modal/extension IDs; +3. известность extension point names; +4. наличие declared exposes в `mf-manifest.json`; +5. совпадение `appCode`, version и remote name; +6. `sdkVersionRange`; +7. отсутствие source paths в runtime artifact; +8. отсутствие host-owned libraries в emitted chunks; +9. отсутствие runtime imports из internal SDK alias; +10. отсутствие запрещённых providers; +11. отсутствие global CSS selectors; +12. immutable asset path; +13. allowed asset origin; +14. content hash; +15. manifest permissions/scopes format. + +Build platform release завершается ошибкой при невалидном App UI artifact. + +### 21.3 Publish + +В v1 App UI не публикуется как независимый release. + +Pipeline: + +```text +build App backend +-> build App Admin remote +-> validate common manifest and artifact +-> upload immutable browser assets +-> generate bundled registry metadata +-> deploy services/apps and Admin +``` + +Версия App меняется вместе с platform build. + +## 22. План реализации + +### Этап 0. Зафиксировать contracts + +1. Добавить `adminUi` в общий App manifest. +2. Добавить `AdminUiBuildArtifact`. +3. Зафиксировать descriptor GraphQL schema. +4. Зафиксировать `AdminAppSdk` props contract. +5. Зафиксировать initial core modal catalog. +6. Зафиксировать initial Orders extension point catalog. + +Результат: backend, Admin host и App producer используют одну терминологию и +один `appCode + version`. + +### Этап 1. Выделить Apps domain + +1. Создать `admin/src/domains/apps`. +2. Перенести page, hooks, modals и GraphQL из + `admin/src/domains/system/apps`. +3. Создать `apps/register.tsx`. +4. Удалить Apps ownership из `system/register.tsx`. +5. Сохранить management URL в `System -> Integrations`. +6. Добавить public barrel `domains/apps/index.ts`. + +Результат: Apps является отдельным Admin domain. + +### Этап 2. Создать internal SDK + +1. Создать `admin/src/domains/apps/sdk`. +2. Добавить portable TypeScript contracts. +3. Создать scoped `AdminAppSdk` factory. +4. Добавить disposed-scope guard. +5. Настроить type-only alias `@shopana/admin-app-sdk`. +6. Запретить runtime imports из alias. +7. Добавить UI, navigation, GraphQL и notification adapters. + +Результат: SDK существует внутри Admin и передаётся remote modules через +props. + +### Этап 3. Backend UI descriptor + +1. Расширить backend App manifest секцией `adminUi`. +2. Включить UI metadata в manifest snapshot/hash. +3. Добавить deployment public assets configuration. +4. Добавить `adminUiApps` и `adminUiApp`. +5. Фильтровать descriptors по installation state, health и scopes. +6. Не создавать отдельную release persistence. + +Результат: Admin узнаёт Apps и URLs только от `services/apps`. + +### Этап 4. Runtime host и page + +1. Добавить `AdminAppsHostProvider`. +2. Добавить descriptor validation. +3. Реализовать owner-scoped registries. +4. Подключить Module Federation Runtime. +5. Добавить canonical catch-all App route. +6. Реализовать page lazy loading. +7. Добавить loading/error/incompatible states. +8. Реализовать store switch cleanup. + +Результат: установленное App открывается на собственной странице. + +### Этап 5. Modal SDK + +1. Добавить owner к modal definitions и stack entries. +2. Добавить typed modal result. +3. Добавить owner-scoped unregister/close. +4. Добавить lazy remote modal loader. +5. Добавить core modal adapters. +6. Реализовать Product details и Product picker. +7. Сохранить nested stack и dirty close confirmation. + +Результат: App использует тот же modal stack, что и core Admin. + +### Этап 6. Theme и UI ownership + +1. Зафиксировать Module Federation singleton allowlist. +2. Добавить producer externalization rules. +3. Создать `AdminAppPage`, `AdminModalLayout` и `AdminDataGrid`. +4. Запретить дополнительные providers и global CSS. +5. Проверить light/dark theme. +6. Проверить Ant Design messages, notifications, dropdowns и portals. +7. Проверить nested modals и AG Grid. + +Результат: App визуально и функционально наследует Admin UI environment. + +### Этап 7. Extension points + +1. Реализовать extension registry. +2. Реализовать `AdminAppExtensionPoint`. +3. Добавить initial Orders point catalog. +4. Вставить points в Orders list/details/fulfillment/shipping. +5. Добавить isolated lazy rendering. +6. Реализовать Nova Poshta contributions. + +Результат: Nova Poshta участвует в стандартных Orders pages без прямого +импорта App source. + +### Этап 8. App build integration + +1. Добавить source unit `admin/src/domains/apps/` в App generator. +2. Добавить MF producer build в unified App tooling. +3. Генерировать `admin-ui.artifact.json`. +4. Добавить App UI validator. +5. Публиковать immutable assets. +6. Добавить artifact metadata в bundled registry. +7. Добавить dev watch для App backend и Admin remote. + +Результат: App backend и UI собираются как один App текущего platform release. + +### Этап 9. Hardening + +1. Добавить CSP и asset origin policy. +2. Добавить hash/integrity verification. +3. Добавить timeout/retry. +4. Добавить telemetry. +5. Добавить deterministic cancellation. +6. Добавить runtime kill switch. +7. Добавить bundle inspection. + +## 23. Acceptance criteria + +### Domain + +- Apps код находится в `admin/src/domains/apps`. +- `admin/src/domains/system/apps` отсутствует. +- System domain не владеет Apps hooks, GraphQL или runtime. +- Management page остаётся доступной из `System -> Integrations`. + +### Page + +- `ACTIVE` App открывается по + `/:orgName/:storeName/apps/:appCode`. +- Deep link и browser refresh работают. +- Nested `appPath` работает без App router. +- Suspended, uninstalled, unhealthy или incompatible App не загружает remote + JS. + +### SDK + +- Runtime SDK создаётся внутри Admin. +- Remote получает scoped SDK через props. +- Отдельный SDK package отсутствует. +- Remote использует только type-only internal alias. +- Disposed SDK scope не выполняет новые операции. + +### Theme + +- App видит текущую light/dark theme. +- Ant Design components используют Admin tokens. +- `App.useApp()` использует host message/modal/notification context. +- CSS-in-JS использует host theme/cache. +- В DOM нет второго `ConfigProvider`, `ThemeProvider` или Ant Design `App`. +- В remote chunks нет второй копии React, Ant Design или AG Grid. + +### Modals + +- App page открывает App-owned modal. +- App modal использует общий full-screen modal stack. +- App modal открывает core Product modal поверх себя. +- Product picker возвращает typed result. +- Dirty close confirmation работает. +- Store switch/uninstall закрывает только modals соответствующего owner. +- Remote modal error не ломает modal stack. + +### AG Grid + +- App использует `sdk.ui.DataGrid`. +- Grid получает current Admin theme, defaults и locale. +- App не регистрирует AG Grid modules. + +### Extensions + +- Nova Poshta contribution рендерится только для `ACTIVE` installation. +- Contribution получает stable Orders context и scoped SDK. +- Ошибка contribution не ломает Order page/modal. +- Ordering deterministic. +- UI extension points не создают backend capability slots. + +### Discovery + +- Admin host, runtime descriptors и bundled registry не содержат filesystem + source paths конкретных Apps. +- Hosted App UI source находится в + `admin/src/domains/apps/`. +- Admin не содержит hardcoded production remote URLs. +- `services/apps` возвращает immutable manifest URL. +- Descriptor соответствует общей App version и installation. +- Все declared modules существуют в MF manifest. +- Отдельная `AppRelease` persistence отсутствует. + +## 24. Ownership + +| Область | Владелец | +|---|---| +| App identity, version и common manifest | App definition | +| Backend runtime | `services/apps` | +| Installation lifecycle | `services/apps` control plane | +| App GraphQL schema/resolvers | App package | +| GraphQL hosting и guards | `services/apps` | +| Admin UI source | `admin/src/domains/apps/` | +| Admin UI artifact build | App build tooling | +| Browser asset hosting metadata | bundled App registry + deployment config | +| Apps management UI | `admin/src/domains/apps/management` | +| Module Federation runtime | `admin/src/domains/apps/runtime` | +| Internal SDK | `admin/src/domains/apps/sdk` | +| React root and contexts | Admin | +| Theme and CSS-in-JS cache | Admin | +| Modal stack | Admin | +| Core modal implementations | соответствующие core domains | +| Core modal public adapters | Admin Apps SDK | +| Extension point locations | core Admin domains | +| Extension registry and rendering | Admin Apps domain | +| AG Grid modules/theme/license | Admin | +| App authorization | backend scopes/RBAC | + +## 25. Возможное развитие после v1 + +После стабилизации contracts допускается вынести только portable часть: + +```text +admin/src/domains/apps/sdk/contracts + -> +packages/admin-app-sdk +``` + +Runtime implementations остаются в Admin и после такого выделения. + +Критерии для package extraction: + +- props contracts стабилизированы; +- минимум два App используют SDK; +- modal и extension point catalogs не меняются хаотично; +- build tooling умеет публиковать совместимую SDK version; +- появился реальный сценарий разработки App вне текущего monorepo. + +До выполнения этих условий отдельный package создаёт лишний release lifecycle +и не используется. diff --git a/admin/docs/assets/customer-details-redesign/01-customer-info-header.png b/admin/docs/assets/customer-details-redesign/01-customer-info-header.png new file mode 100644 index 000000000..eaa5a945b Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/01-customer-info-header.png differ diff --git a/admin/docs/assets/customer-details-redesign/02-customer-profile.png b/admin/docs/assets/customer-details-redesign/02-customer-profile.png new file mode 100644 index 000000000..fede9fcee Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/02-customer-profile.png differ diff --git a/admin/docs/assets/customer-details-redesign/03-order-activity.png b/admin/docs/assets/customer-details-redesign/03-order-activity.png new file mode 100644 index 000000000..fbb927173 Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/03-order-activity.png differ diff --git a/admin/docs/assets/customer-details-redesign/04-addresses.png b/admin/docs/assets/customer-details-redesign/04-addresses.png new file mode 100644 index 000000000..33a8f824d Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/04-addresses.png differ diff --git a/admin/docs/assets/customer-details-redesign/05-marketing-consents.png b/admin/docs/assets/customer-details-redesign/05-marketing-consents.png new file mode 100644 index 000000000..0a68b583b Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/05-marketing-consents.png differ diff --git a/admin/docs/assets/customer-details-redesign/06-classification.png b/admin/docs/assets/customer-details-redesign/06-classification.png new file mode 100644 index 000000000..7a627aa5c Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/06-classification.png differ diff --git a/admin/docs/assets/customer-details-redesign/07-tax.png b/admin/docs/assets/customer-details-redesign/07-tax.png new file mode 100644 index 000000000..88b4ad5e8 Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/07-tax.png differ diff --git a/admin/docs/assets/customer-details-redesign/08-account-notes.png b/admin/docs/assets/customer-details-redesign/08-account-notes.png new file mode 100644 index 000000000..a12ac6991 Binary files /dev/null and b/admin/docs/assets/customer-details-redesign/08-account-notes.png differ diff --git a/admin/docs/assets/review-details-redesign/01-review-info-header.png b/admin/docs/assets/review-details-redesign/01-review-info-header.png new file mode 100644 index 000000000..cd2b7ba5e Binary files /dev/null and b/admin/docs/assets/review-details-redesign/01-review-info-header.png differ diff --git a/admin/docs/assets/review-details-redesign/02-review-content.png b/admin/docs/assets/review-details-redesign/02-review-content.png new file mode 100644 index 000000000..90edeef5a Binary files /dev/null and b/admin/docs/assets/review-details-redesign/02-review-content.png differ diff --git a/admin/docs/assets/review-details-redesign/03-review-moderation.png b/admin/docs/assets/review-details-redesign/03-review-moderation.png new file mode 100644 index 000000000..9d199c9ab Binary files /dev/null and b/admin/docs/assets/review-details-redesign/03-review-moderation.png differ diff --git a/admin/docs/assets/review-details-redesign/04-product-purchase.png b/admin/docs/assets/review-details-redesign/04-product-purchase.png new file mode 100644 index 000000000..454d2983d Binary files /dev/null and b/admin/docs/assets/review-details-redesign/04-product-purchase.png differ diff --git a/admin/docs/assets/review-details-redesign/05-review-ratings.png b/admin/docs/assets/review-details-redesign/05-review-ratings.png new file mode 100644 index 000000000..0b9e55494 Binary files /dev/null and b/admin/docs/assets/review-details-redesign/05-review-ratings.png differ diff --git a/admin/docs/assets/review-details-redesign/06-customer-media.png b/admin/docs/assets/review-details-redesign/06-customer-media.png new file mode 100644 index 000000000..d3c52c97d Binary files /dev/null and b/admin/docs/assets/review-details-redesign/06-customer-media.png differ diff --git a/admin/docs/assets/review-details-redesign/07-review-replies.png b/admin/docs/assets/review-details-redesign/07-review-replies.png new file mode 100644 index 000000000..f6a42e37b Binary files /dev/null and b/admin/docs/assets/review-details-redesign/07-review-replies.png differ diff --git a/admin/docs/assets/review-details-redesign/08-trust-incentive.png b/admin/docs/assets/review-details-redesign/08-trust-incentive.png new file mode 100644 index 000000000..05e16fc14 Binary files /dev/null and b/admin/docs/assets/review-details-redesign/08-trust-incentive.png differ diff --git a/admin/docs/assets/review-details-redesign/09-author-source.png b/admin/docs/assets/review-details-redesign/09-author-source.png new file mode 100644 index 000000000..2f549786c Binary files /dev/null and b/admin/docs/assets/review-details-redesign/09-author-source.png differ diff --git a/admin/docs/assets/review-details-redesign/10-external-references.png b/admin/docs/assets/review-details-redesign/10-external-references.png new file mode 100644 index 000000000..aad327bfa Binary files /dev/null and b/admin/docs/assets/review-details-redesign/10-external-references.png differ diff --git a/admin/docs/assets/review-details-redesign/11-edit-review-moderation.png b/admin/docs/assets/review-details-redesign/11-edit-review-moderation.png new file mode 100644 index 000000000..f913fb586 Binary files /dev/null and b/admin/docs/assets/review-details-redesign/11-edit-review-moderation.png differ diff --git a/admin/docs/assets/review-details-redesign/12-edit-review-media-details.png b/admin/docs/assets/review-details-redesign/12-edit-review-media-details.png new file mode 100644 index 000000000..ca024ef64 Binary files /dev/null and b/admin/docs/assets/review-details-redesign/12-edit-review-media-details.png differ diff --git a/admin/docs/assets/system-notifications/01-notifications.png b/admin/docs/assets/system-notifications/01-notifications.png new file mode 100644 index 000000000..b7fc30aa8 Binary files /dev/null and b/admin/docs/assets/system-notifications/01-notifications.png differ diff --git a/admin/docs/assets/system-notifications/02-customer-notifications.png b/admin/docs/assets/system-notifications/02-customer-notifications.png new file mode 100644 index 000000000..daf1a3a4b Binary files /dev/null and b/admin/docs/assets/system-notifications/02-customer-notifications.png differ diff --git a/admin/docs/assets/system-notifications/03-staff-notifications.png b/admin/docs/assets/system-notifications/03-staff-notifications.png new file mode 100644 index 000000000..a2303cf56 Binary files /dev/null and b/admin/docs/assets/system-notifications/03-staff-notifications.png differ diff --git a/admin/docs/assets/system-notifications/04-email-template.png b/admin/docs/assets/system-notifications/04-email-template.png new file mode 100644 index 000000000..98a38e7df Binary files /dev/null and b/admin/docs/assets/system-notifications/04-email-template.png differ diff --git a/admin/docs/assets/system-notifications/05-sms-template.png b/admin/docs/assets/system-notifications/05-sms-template.png new file mode 100644 index 000000000..c88716f38 Binary files /dev/null and b/admin/docs/assets/system-notifications/05-sms-template.png differ diff --git a/admin/docs/assets/system-notifications/06-webhooks.png b/admin/docs/assets/system-notifications/06-webhooks.png new file mode 100644 index 000000000..b64520830 Binary files /dev/null and b/admin/docs/assets/system-notifications/06-webhooks.png differ diff --git a/admin/docs/assets/system-notifications/07-add-webhook.png b/admin/docs/assets/system-notifications/07-add-webhook.png new file mode 100644 index 000000000..ca743a7ac Binary files /dev/null and b/admin/docs/assets/system-notifications/07-add-webhook.png differ diff --git a/admin/docs/assets/system-notifications/08-smtp-settings.png b/admin/docs/assets/system-notifications/08-smtp-settings.png new file mode 100644 index 000000000..366dca2ef Binary files /dev/null and b/admin/docs/assets/system-notifications/08-smtp-settings.png differ diff --git a/admin/docs/assets/system-notifications/09-sms-gateway.png b/admin/docs/assets/system-notifications/09-sms-gateway.png new file mode 100644 index 000000000..bae422c04 Binary files /dev/null and b/admin/docs/assets/system-notifications/09-sms-gateway.png differ diff --git a/admin/docs/bundle-details-product-operations-integration-plan.md b/admin/docs/bundle-details-product-operations-integration-plan.md new file mode 100644 index 000000000..0a85fd62b --- /dev/null +++ b/admin/docs/bundle-details-product-operations-integration-plan.md @@ -0,0 +1,265 @@ +# План интеграции product-модалок и операций в Bundle Details Modal + +## Цель + +Сделать `Bundle Details Modal` полноценным product-backed экраном: bundle является тем же sellable `Product` с `kind = BUNDLE`, поэтому общие product UI-компоненты, модалки, hooks и GraphQL helpers должны переиспользоваться без дублирования. + +Исключение: bundle-specific часть должна остаться на отдельных bundle API: + +- bundle configurations; +- bundle groups / items; +- pricing templates и pricing dependency rules; +- dependency rules chart / sync. + +## Текущее состояние + +- `admin/src/domains/promos/bundles/modals/bundle-modal/index.tsx` открывает modal по `entityId`, но не использует его для API-загрузки. Сейчас показываются `mockBundleProduct` и `bundleDetailsMockData`. +- `admin/src/domains/promos/bundles/components/bundle-details-card/bundle-details-card.tsx` уже импортирует часть product UI: + - `ProductInfoHeader`; + - `ProductContentTabs`; + - `PricingBlock`; + - `MediaSection`; + - `CategoriesSection`; + - `TagsSection`; + - `AttributesSection`; + - `SeoBlock`. +- Bundle details сейчас вручную строит `apiProduct` через `createMockApiProduct`, а media/seo handlers только логируют сохранение или показывают mock-сообщения. +- Product details уже имеет рабочую orchestration-модель: + - `ProductModal` загружает product через `useProduct`; + - `ProductDetailsCard` принимает `ApiProduct`; + - `useProductModals` открывает media/options/attributes/seo/variants modals и сохраняет через `useUpdateProduct`; + - `ProductInfoHeader`, `ProductContentTabs`, `PricingBlock`, `CategoriesSection`, `TagsSection` сами используют существующие product API helpers. + +## Архитектурное решение + +`BundleModal` должен загружать bundle как API entity и передавать общий product-compatible объект в переиспользуемый product слой. Так как generated `ApiBundle` структурно содержит те же product-поля (`id`, `title`, `handle`, `description`, `excerpt`, `media`, `features`, `options`, `variants`, `variantsCount`, `seo`, `tags`, `categoryAssignments`, `primaryCategory`, `revision`, `isPublished`), нужно минимизировать adapter-код и не создавать отдельные bundle-копии product-модалок. + +Рекомендуемый подход: + +1. Добавить bundle GraphQL details fragment/query/hook рядом с `admin/src/domains/promos/bundles`. +2. Для общей product части использовать те же components/hooks, что и product details. +3. Для bundle-specific sections оставить отдельный state/API слой и отдельные modals из `admin/src/domains/promos/bundles/modals.ts`. +4. Если TypeScript не принимает `ApiBundle` там, где ожидается `ApiProduct`, ввести узкий shared тип `ProductDetailsEntity`, а не кастовать `ApiBundle as ApiProduct` по месту. + +## API слой для bundle details + +Добавить файлы: + +- `admin/src/domains/promos/bundles/graphql/fragments.ts` +- `admin/src/domains/promos/bundles/graphql/queries.ts` +- `admin/src/domains/promos/bundles/graphql/mutations.ts` +- `admin/src/domains/promos/bundles/graphql/operation-types.ts` +- `admin/src/domains/promos/bundles/hooks/use-bundle.ts` +- `admin/src/domains/promos/bundles/hooks/use-update-bundle.ts` + +Минимальный details query: + +- `catalogQuery.bundle(id: $id)`; +- общие product поля должны совпадать с `PRODUCT_EDITOR_BASE_FRAGMENT`; +- `variants(first: $variantsFirst, after: $variantsAfter)` должен совпадать с product details; +- bundle-specific fields: + - `type`; + - `displayStyle`; + - `configurations`; + - `configurations.groups.items`; + - `configurations.pricingTemplates`; + - `configurations.dependencyRules`. + +Важно: общие product операции не должны идти через `bundleUpdate`, если уже есть рабочий `productUpdate`. Для title/handle/content/media/seo/tags/variants/status/archive использовать существующие product helpers: + +- `useUpdateProduct`; +- `useUpdateProductStatus`; +- `useDeleteProduct`; +- category hooks; +- variants loader/update mappers; +- pricing widget hooks. + +Bundle-specific mutations держать отдельно: + +- `bundleUpdate` для `type`, `displayStyle` и других bundle root settings; +- `bundleConfigurationCreate`; +- `bundleConfigurationUpdate`; +- `bundleConfigurationDelete`; +- `bundleGroupsSync`; +- `bundlePricingTemplatesSync`; +- `bundleDependencyRulesSync`. + +## Изменения в BundleModal + +Файл: `admin/src/domains/promos/bundles/modals/bundle-modal/index.tsx` + +Заменить mock-загрузку на API-загрузку: + +- получить `entityId` из `payload.entityId`; +- использовать `useBundle({ id: entityId, variantsFirst, variantsAfter })`; +- повторить pagination state из `ProductModal` для variants table; +- показывать skeleton/error/not found аналогично `ProductModal`; +- передавать `refetch` в `BundleDetailsCard` как `onBundleRefresh` / `onProductRefresh`. + +После этого удалить зависимость modal от: + +- `mockBundleProduct`; +- `bundleDetailsMockData`; +- искусственного `setTimeout` loading. + +## Изменения в BundleDetailsCard + +Файл: `admin/src/domains/promos/bundles/components/bundle-details-card/bundle-details-card.tsx` + +Целевой props: + +- `bundle: ApiBundle`; +- `variantsTableData`; +- `onVariantsPageChange`; +- `isVariantsPageLoading`; +- `onBundleRefresh`. + +Удалить: + +- `IProduct` mock prop; +- `IBundleDetailsMockData`; +- `createMockApiProduct`; +- локальный `apiProduct`; +- mock-derived `attributeFeatures`; +- console-only handlers для media/seo/reviews. + +Общая product часть должна переиспользовать существующие components: + +- `ProductInfoHeader` для title/handle/status/archive/share/storefront; +- `ProductContentTabs` для description/excerpt/AI writer; +- `PricingBlock` для pricing widget, price edit modal и price history; +- `MediaSection` + `useProductModals(...).editMedia`; +- `InventorySection`; +- `CategoriesSection`; +- `AttributesSection` + `useProductModals(...).editAttributes`; +- `OptionsSection` + `useProductModals(...).editOptions`; +- `VariantsTableSection` + `useProductModals(...).editVariants`; +- `TagsSection`; +- `SeoBlock` + `useProductModals(...).editSeo`; +- `ReviewsSection` пока оставить mock/placeholder только если reviews API отсутствует, но не блокировать остальные product operations. + +Bundle-specific часть оставить отдельной: + +- `BundleSection`; +- `EditGroupsModal`; +- `EditConfigurationModal`; +- `EditTemplatesModal`; +- `EditSettingsModal`; +- `VariantSettingsModal`; +- `DependencyChartModal`. + +## Общий тип для product-compatible entity + +Сейчас многие product components типизированы как `ApiProduct`. Так как `ApiBundle` имеет почти тот же contract, возможны два варианта: + +### Вариант A: shared type + +Добавить в product domain shared тип: + +```ts +export type ProductDetailsEntity = Pick< + ApiProduct, + | "id" + | "title" + | "handle" + | "isPublished" + | "publishedAt" + | "createdAt" + | "updatedAt" + | "deletedAt" + | "revision" + | "variantsCount" + | "description" + | "excerpt" + | "seo" + | "media" + | "primaryCategory" + | "categoryAssignments" + | "tags" + | "features" + | "options" + | "variants" + | "vendor" +>; +``` + +Потом заменить props в shared product components с `ApiProduct` на `ProductDetailsEntity`, если компонент не использует поля, которых нет у bundle. + +### Вариант B: fragment-level alias + +Если generated `ApiBundle` и `ApiProduct` совместимы структурно, можно сначала использовать `bundle` напрямую в product components. Но если появятся TS-ошибки из-за `__typename` или discriminated fields, перейти к варианту A. Локальные касты допустимы только как временный migration step и не должны размазываться по компонентам. + +## Карта переиспользования product operations + +| Область | Product source | Для bundle | +| --- | --- | --- | +| Title / handle | `ProductInfoHeader` + `useProductEditTitleModal` + `useUpdateProduct` | Переиспользовать полностью | +| Publish / unpublish | `ProductInfoHeader` + `useUpdateProductStatus` | Переиспользовать полностью | +| Archive | `ProductInfoHeader` + `useDeleteProduct` | Переиспользовать полностью, если backend разрешает delete для `kind = BUNDLE` | +| Description / excerpt | `ProductContentTabs` + `EditDescriptionModal` + `useUpdateProduct` | Переиспользовать полностью | +| AI writer | `ProductContentTabs` + `ProductAIWriterModal` | Переиспользовать полностью | +| Media | `useProductModals.editMedia` + `EditMediaModal` + `productUpdate.media` | Переиспользовать полностью | +| Pricing widget | `PricingBlock` | Переиспользовать полностью | +| Price edit | `PricingBlock` / `EditVariantsModal` restricted columns | Переиспользовать полностью | +| Price history | `PriceHistoryModal` | Переиспользовать полностью | +| Inventory widget | `InventorySection` | Переиспользовать полностью | +| Categories | `CategoriesSection` + category assignment hooks | Переиспользовать полностью | +| Tags | `TagsSection` + `productUpdate.tags` | Переиспользовать API-backed section полностью; старый modal handler не нужен | +| Attributes | `EditAttributesModal` + product feature sync | Переиспользовать полностью | +| Options | `EditOptionsModal` + product option sync | Переиспользовать полностью | +| Variants | `VariantsTableSection` + `EditVariantsModal` + variant operation mappers | Переиспользовать полностью | +| SEO / OG | `EditSeoModal` + `productUpdate.seo` | Переиспользовать полностью | +| Bulk editor | `BulkEditorModal` accepts product IDs | Можно открывать для bundle IDs, если backend bulk update supports `kind = BUNDLE` | + +## Bundle-specific operations + +Эти операции не переводить на product helpers: + +| Область | API | +| --- | --- | +| Create configuration | `bundleConfigurationCreate` | +| Rename configuration | `bundleConfigurationUpdate` | +| Delete configuration | `bundleConfigurationDelete` | +| Groups and items | `bundleGroupsSync` | +| Item variant settings | part of groups/items sync payload | +| Pricing templates | `bundlePricingTemplatesSync` | +| Dependency rules | `bundleDependencyRulesSync` | +| Bundle display/settings | `bundleUpdate` | + +Bundle-specific modals должны получать API-backed data из active `ApiBundleConfiguration`, а после save вызывать `onBundleRefresh` или обновлять Apollo cache. + +## Порядок реализации + +1. Создать bundle GraphQL details fragment/query с полями product-compatible + bundle-specific. +2. Создать `useBundle`, который повторяет `useProduct` по loading/error/refetch/previousData behavior. +3. Перевести `BundleModal` с mock data на `useBundle`. +4. Ввести `ProductDetailsEntity` или другой общий narrow type, если `ApiBundle` не проходит в product components. +5. Переподключить `BundleDetailsCard` к real `bundle` и `useProductModals`. +6. Добавить в `BundleDetailsCard` те product sections, которых сейчас нет или которые не API-backed: + - inventory; + - options; + - variants table; + - real tags operations; + - real attributes operations; + - real media/seo save. +7. Перевести bundle configurations/groups/templates/dependency rules с local state на bundle-specific mutations. +8. Обновить `useBundles` и bundles page позже отдельным шагом: список сейчас mock-backed, но details modal может быть API-backed независимо. +9. Удалить устаревшие mock adapters из bundle details после перехода. + +## Риски и проверки + +- `ApiBundle.__typename = "Bundle"` может конфликтовать с props, которые ожидают `ApiProduct.__typename = "Product"`. Решение: shared narrow type. +- `ProductInfoHeader` использует product wording в toast messages. На первом этапе это допустимо для переиспользования API, но после интеграции стоит заменить copy на entity-neutral labels или добавить optional `entityLabel`. +- `PricingBlock` уже умеет работать по `productId`, но price edit требует `product`. Для bundle нужно передавать full product-compatible bundle, иначе edit button будет скрыт. +- `CategoriesSection` использует wording "product". API подходит, copy можно вынести позже. +- `TagsSection` уже API-backed; не использовать старый `useProductModals.editTags`, где сейчас есть placeholder. +- `ReviewsSection` не является частью product API operations в текущем коде, поэтому reviews можно оставить без изменений. +- Проверку не выполнять через `test` или `tsc`. После code implementation запускать `npm run build`, если нужна проверка новой версии кода. + +## Definition of Done + +- Bundle modal открывается по real `entityId` и показывает API-backed bundle. +- Все общие product edits в bundle details используют те же product modals/helpers, что product details. +- После сохранения common product operations обновляется bundle modal state/refetch. +- Bundle configurations/groups/items/pricing dependency rules остаются на bundle-specific API. +- В bundle details больше нет `mockBundleProduct`, `bundleDetailsMockData`, `createMockApiProduct` и console-only save handlers для product операций. diff --git a/admin/docs/categories-page-table-relay-integration-plan.md b/admin/docs/categories-page-table-relay-integration-plan.md new file mode 100644 index 000000000..c0dcb318a --- /dev/null +++ b/admin/docs/categories-page-table-relay-integration-plan.md @@ -0,0 +1,374 @@ +# План интеграции Categories Page с Relay pagination table + +## Цель + +Перевести `admin/src/domains/inventory/categories/page/page.tsx` с mock-only списка на Admin GraphQL API через `catalogQuery.categories`, сохранив паттерны проекта: + +- module-local GraphQL layer: `graphql/fragments.ts`, `queries.ts`, `operation-types.ts`, `index.ts`; +- API-backed hook в `hooks/use-categories.ts`; +- компоненты страницы consume generated API shapes из `@/graphql/types` напрямую; +- таблица использует Relay cursor pagination, `totalCount` и `pageInfo`; +- mocks не импортируются в API-backed hook/page. + +План выровнен с `knowledge/vault/patterns/admin-graphql-layer.md`, текущими warehouse/inventory page паттернами и generated Admin API types. + +## Текущий baseline + +- `admin/src/domains/inventory/categories/page/page.tsx` рендерит `AgGridReact`. +- `useCategories` симулирует задержку и возвращает `mockCategoriesList` из `@/mocks/products/categories-list`. +- `CursorPagination` на странице статический: `total={categories.length}`, `pageSize={30}`, `hasNext={false}`. +- create/delete сейчас только логируются. +- row click открывает `push("category", { level: 1 })`, без `entityId`. +- `filter-schema.ts` содержит mock/API-mismatched поля `status`, `name`, `productsCount`. + +## Доступный API contract + +Generated frontend types уже содержат: + +- `ApiCategory` +- `ApiCategoryConnection` +- `ApiCategoryWhereInput` +- `ApiCategoryOrderByInput` +- `CategoryOrderField` +- `SortDirection` +- `ApiPageInfo` + +`CatalogQuery.categories`: + +```graphql +categories( + first: Int + after: String + last: Int + before: String + where: CategoryWhereInput + orderBy: [CategoryOrderByInput!] +): CategoryConnection! +``` + +Текущий `CategoryWhereInput` поддерживает `id`, `parentId`, `path`, `depth`, `handle`, `defaultSort`, `defaultSortDirection`, `publishedAt`, `createdAt`, `updatedAt`. + +Текущий `CategoryOrderField` поддерживает `id`, `parentId`, `path`, `depth`, `handle`, `defaultSort`, `defaultSortDirection`, `publishedAt`, `createdAt`, `updatedAt`. + +Важное ограничение: `CategoryWhereInput` и `CategoryOrderField` сейчас не содержат `name`, `isPublished`, `productsCount`. Поэтому первый API-backed вариант не должен притворяться, что search/filter/sort по этим полям серверные. Для них нужен отдельный backend/schema follow-up или временное отключение соответствующих UI controls. + +## Target file structure + +Добавить module-local GraphQL слой: + +```text +admin/src/domains/inventory/categories/ + graphql/ + fragments.ts + queries.ts + operation-types.ts + index.ts + hooks/ + use-categories.ts + index.ts + page/ + filter-schema.ts + page.tsx +``` + +Не реэкспортить generated API types из module barrels. В местах использования импортировать типы напрямую из `@/graphql/types`. + +## GraphQL operations + +### `graphql/fragments.ts` + +```ts +import { gql } from "@apollo/client"; + +export const CATEGORY_LIST_FRAGMENT = gql` + fragment CategoryListFields on Category { + id + name + handle + isPublished + publishedAt + productsCount + depth + path + createdAt + updatedAt + media { + sortIndex + file { + id + url + originalName + mimeType + altText + } + } + parent { + id + name + handle + } + } +`; +``` + +Notes: + +- Keep the list fragment compact. Do not include `children`, `ancestors`, `description`, `seo`, or `products`. +- Use `media[0].file` only as a best-effort thumbnail on the page. +- API date/time scalars stay strings; format only at the display boundary. + +### `graphql/queries.ts` + +```ts +import { gql } from "@apollo/client"; +import { CATEGORY_LIST_FRAGMENT } from "./fragments"; + +export const CATEGORIES_QUERY = gql` + query Categories( + $first: Int + $after: String + $last: Int + $before: String + $where: CategoryWhereInput + $orderBy: [CategoryOrderByInput!] + ) { + catalogQuery { + categories( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...CategoryListFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + ${CATEGORY_LIST_FRAGMENT} +`; +``` + +### `graphql/operation-types.ts` + +```ts +import type { + ApiCatalogQuery, + ApiCategoryConnection, + ApiCategoryOrderByInput, + ApiCategoryWhereInput, +} from "@/graphql/types"; + +export interface CategoriesQueryData { + catalogQuery: Pick & { + categories: ApiCategoryConnection; + }; +} + +export interface CategoriesQueryVariables { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: ApiCategoryWhereInput | null; + orderBy?: ApiCategoryOrderByInput[] | null; +} +``` + +## Hook contract + +Replace mock logic in `hooks/use-categories.ts` with Apollo `useQuery`. + +```ts +export interface UseCategoriesOptions extends RelayCursorPaginationVariables { + where?: ApiCategoryWhereInput | null; + orderBy?: ApiCategoryOrderByInput[] | null; + skip?: boolean; +} + +interface UseCategoriesReturn { + categories: ApiCategory[]; + connection: ApiCategoryConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} +``` + +Implementation rules: + +- Use `CATEGORIES_QUERY`. +- Use `fetchPolicy: "cache-and-network"`. +- Use `data ?? previousData` to avoid grid flicker during pagination. +- Return `connection?.edges.map((edge) => edge.node) ?? []`. +- Do not import from `@/mocks`. +- Keep output API-shaped: return `ApiCategory[]`, not `CategoryListItem` view models. + +## Page integration + +### Types and table data + +- Replace `ICategoryListItem` with `ApiCategory`. +- `rowData={categories}`. +- `getRowId={(params) => params.data.id}`. +- `DataLayout.count={totalCount}`. +- Pass `loading` to `AgGridReact`. +- Render `Alert` for `error` like warehouse page. + +### Pagination + +Use the newer warehouse/inventory pattern: + +- import `RelayCursorPagination` and `useRelayCursorPagination` from `@/ui-kit/cursor-pagination`; +- create `resetKey` from API-backed parameters such as `where` and `orderBy`; +- pass `...pagination.variables` into `useCategories`; +- render: + +```tsx + +``` + +Do not keep the old static `CursorPagination` values. + +### Columns + +Initial API-backed columns: + +- `Category`: thumbnail from sorted `category.media[0]?.file.url`, fallback `FolderOutlined`, and `category.name`. +- `Status`: derive from `category.isPublished` as `Published` / `Draft`. +- `Products`: `category.productsCount`. +- `Parent`: `category.parent?.name ?? "Root"`. +- `Updated`: formatted `category.updatedAt`. + +Sorting must be server-side only for supported API fields: + +```ts +const CATEGORY_SORT_FIELDS: Partial> = { + handle: CategoryOrderField.Handle, + depth: CategoryOrderField.Depth, + updatedAt: CategoryOrderField.UpdatedAt, + createdAt: CategoryOrderField.CreatedAt, + publishedAt: CategoryOrderField.PublishedAt, +}; +``` + +Columns without API sort support must have `sortable: false` or no mapping: + +- `name` +- `isPublished` +- `productsCount` +- `parent.name` + +### Search and filters + +First API-backed version should avoid unsupported server-backed controls. + +Recommended initial behavior: + +- Search by `handle` only, or label placeholder accordingly, because `name` is not in `CategoryWhereInput`. +- Replace/limit `filter-schema.ts` to fields backed by `CategoryWhereInput`, for example `handle`, `depth`, `publishedAt`, `createdAt`, `updatedAt`. +- For status, either defer the filter or map: + - published: `publishedAt: { _isNot: true }` + - draft: `publishedAt: { _is: true }` + This only works if API semantics are "publishedAt null means draft"; verify before implementing. +- Do not expose products count filter until API adds `productsCount` to `CategoryWhereInput`. + +If product owner requires `name/status/productsCount` filtering in this iteration, add a backend follow-up before frontend wiring: + +- add generated filter support for `name`; +- decide whether `isPublished` should be filterable directly or derived from `publishedAt`; +- add filter/sort support for `productsCount` only if repository query can do it efficiently. + +### Row actions + +- On row click open the existing category modal with the real entity id: + +```ts +onRowAction: (category) => push("category", { entityId: category.id }) +``` + +- Keep create/delete out of scope unless the same task adds `categoryCreate` / `categoryDelete` hooks. +- If delete selection remains visible before mutation integration, disable it with tooltip or remove it from `selectionActions`. + +## Implementation phases + +### 1. Add category GraphQL module + +- Create `categories/graphql/fragments.ts`. +- Create `categories/graphql/queries.ts`. +- Create `categories/graphql/operation-types.ts`. +- Create `categories/graphql/index.ts` barrel for operation documents and operation-local TS types only. + +### 2. Replace `useCategories` + +- Remove mock delay/state implementation. +- Add Apollo `useQuery`. +- Return `categories`, `connection`, `totalCount`, `pageInfo`, `loading`, `error`, `refetch`. +- Keep hook API consistent with warehouse/products hooks. + +### 3. Wire page to API data + +- Update imports and `AgGridReact`. +- Update cell renderers to read `ApiCategory` fields directly. +- Add loading/error states. +- Replace static count/pagination with `totalCount`, `pageInfo`, and `RelayCursorPagination`. +- Pass real `entityId` to category modal. + +### 4. Add server-side sort mapping + +- Add `sortModel` state. +- Add `mapCategorySortModelToOrderBy`. +- Use `useGridSort` controlled by `sortModel`. +- Disable or ignore unsupported sort columns. +- Reset pagination when `orderBy` changes. + +### 5. Align search/filter controls + +- Decide first-scope UX: + - minimal: keep only handle/depth/date filters; + - or backend follow-up for `name`, `isPublished`, `productsCount`. +- Ensure `where` only contains fields from `ApiCategoryWhereInput`. +- Reset Relay pagination on `where` changes. + +## Verification + +Project instruction says not to run tests or `tsc` for verification. + +Recommended verification for the implementation PR: + +- Run build only if a fresh compiled version is needed. +- Manually inspect the page in Admin: + - table loads from `catalogQuery.categories`; + - next/previous pagination uses cursors; + - `DataLayout` count equals API `totalCount`; + - row click opens category modal with `entityId`; + - unsupported sort/filter controls are not shown as server-backed. + +## Acceptance criteria + +- `admin/src/domains/inventory/categories/hooks/use-categories.ts` no longer imports mocks. +- Categories page uses `ApiCategory[]` from `@/graphql/types`. +- `CATEGORIES_QUERY` uses Relay variables and requests `pageInfo` + `totalCount`. +- Pagination is driven by `RelayCursorPagination`. +- The page does not send unsupported `where` or `orderBy` fields. +- `name`, `isPublished`, and `productsCount` API gaps are either deferred in UI or covered by a backend/schema change before frontend use. diff --git a/admin/docs/category-create-modal-plan.md b/admin/docs/category-create-modal-plan.md new file mode 100644 index 000000000..b39ca7a04 --- /dev/null +++ b/admin/docs/category-create-modal-plan.md @@ -0,0 +1,641 @@ +# План: Create Category Modal UI + API Integration + +## Цель + +Сделать модалку создания категории по дизайну существующей модалки создания продукта. + +Ключевое правило: **не проектировать новую категорийную модалку**. Нужно взять текущий паттерн `CreateProductModal` и заменить продуктовые поля на категорийные там, где это прямо необходимо. + +Источник дизайна: + +- `admin/src/domains/inventory/products/modals/create-product-modal/create-product-modal.tsx` +- `admin/src/domains/inventory/products/modals/create-product-modal/general-section.tsx` +- `admin/src/domains/inventory/products/modals/create-product-modal/media-section.tsx` + +## Design Lock + +В первой версии модалки должны быть только секции, которые соответствуют create product modal: + +```text +CreateProductModal +├── General +├── Media +└── Variants + +CreateCategoryModal +├── General +└── Media +``` + +`Variants` не переносится, потому что у категории нет вариаций. Вместо него нельзя добавлять выдуманную секцию `Category settings`. + +Запрещено добавлять в create category modal: + +- publish/status switch; +- подсказки про draft/publish; +- отдельную `SEO` секцию; +- отдельную `Preview` секцию; +- отдельную `Category settings` секцию; +- instructional copy, которого нет в product create modal; +- дополнительные controls только потому, что API их поддерживает. + +Если поле не имеет прямого аналога в create product modal, оно не попадает в основной create UI. + +## Product To Category Mapping + +| Product create | Category create | +|---|---| +| `New Product` | `New Category` | +| `Title` | `Name` | +| `Handle` | `Handle` | +| `Description` | `Description` | +| `Media` | `Media` | +| `Variants` | Omitted | + +## Main Wireframe + +```text +┌──────────────────────────────────────────────────────────────────┐ +│ × New Category [Create] │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ General ───────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Name Handle (?) │ │ +│ │ [Summer essentials_____] [/│summer-essentials____] │ │ +│ │ │ │ +│ │ Description │ │ +│ │ [Rich text editor_____________________________________] │ │ +│ │ [_____________________________________________________] │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Media ─────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ │ +│ │ │ Upload images │ │ │ +│ │ │ Drag and drop images here or click. │ │ │ +│ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +This must visually match product create: + +- same `ModalLayout`; +- same `ModalHeader`; +- same body container `display: flex`, `flexDirection: column`, `gap: 16`; +- same `Paper` and `PaperHeader`; +- same two-column first row in `General`; +- same handle input with `/` addon; +- same `Editor` treatment for description; +- same `EntityMediaGallery` for media. + +## General Section + +Near-copy of product `GeneralSection`, with field names changed. + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ General │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Name Handle (?) │ +│ ┌────────────────────────┐ ┌────────────────────────────┐ │ +│ │ Summer essentials │ │ / │ summer-essentials │ │ +│ └────────────────────────┘ └────────────────────────────┘ │ +│ │ +│ Description │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Rich text editor │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +Fields: + +| Field | Control | Required | Product equivalent | +|---|---|---|---| +| `name` | `Input` | Yes | `title` | +| `handle` | `Input` with `/` addon | Yes | `handle` | +| `description` | `Editor` | No | `description` | + +Handle behavior mirrors product create: + +```ts +const [isHandleManual, setIsHandleManual] = useState(false); +const name = watch("name"); + +useEffect(() => { + if (!isHandleManual && name) { + setValue("handle", slugify(name)); + } +}, [name, isHandleManual, setValue]); +``` + +Tooltip: + +```text +URL-friendly identifier. Auto-generated from name if left empty. +``` + +## Media Section + +Exact same pattern as product create media. + +```tsx + +``` + +Mapping: + +- Form stores uploaded `ApiFile[]`. +- Submit sends `mediaFileIds: media.map((file) => file.id)`. +- Empty media omits `mediaFileIds`. +- First media item is featured by ordering only. The create category API has no separate featured image field. + +## Out Of UI But Supported By Submit Context + +`CategoryCreateInput.parentId` is useful for "create subcategory", but showing a parent selector would add UI that does not exist in product create. + +First implementation rule: + +- Categories page create button opens modal without `parentId`; category is created at root/API default. +- Future "add subcategory" action may open the same modal with `payload.parentId`. +- The modal can submit `parentId` from payload, but does not render a parent selector. + +This keeps the UI identical to product create while preserving API compatibility for subcategory entry points. + +## Fields Intentionally Omitted + +`CategoryCreateInput` supports optional fields that are not part of the first modal UI: + +| API field | Reason omitted | +|---|---| +| `parentId` | Not visible in root create UI; may come from modal payload for subcategory flow. | +| `excerpt` | Product create modal has no excerpt field. | +| `seo` | Product create modal has no SEO section. | +| `publish` | Product create modal has no publish/status switch. | + +Do not add these fields to the UI unless the product create modal gains equivalent create-time controls or the task explicitly changes the design requirement. + +## Form State + +```ts +interface CreateCategoryFormValues { + name: string; + handle: string; + description: OutputData | null; + media: ApiFile[]; +} +``` + +Default values: + +```ts +const DEFAULT_VALUES: CreateCategoryFormValues = { + name: "", + handle: "", + description: null, + media: [], +}; +``` + +Modal payload: + +```ts +interface ICreateCategoryModalPayload extends IModalStackPayload { + parentId?: string | null; + onCreated?: (category: ApiCategory) => void; +} +``` + +## API Mapping + +Target mutation: + +```graphql +mutation CategoryCreate($input: CategoryCreateInput!) { + catalogMutation { + categoryCreate(input: $input) { + category { + id + name + handle + isPublished + publishedAt + productsCount + depth + path + createdAt + updatedAt + media { + sortIndex + file { + id + url + originalName + mimeType + altText + } + } + parent { + id + name + handle + } + } + userErrors { + ...UserErrorFields + } + } + } +} +``` + +Mapping table: + +| Source | API field | Rule | +|---|---|---| +| `values.name` | `name` | Trim and send. | +| `values.handle` | `handle` | Slugify on input, send slug. | +| `values.description` | `description` | Convert EditorJS through `renderContent`; omit when empty. | +| `values.media` | `mediaFileIds` | Send uploaded file IDs in gallery order; omit when empty. | +| `payload.parentId` | `parentId` | Send only when modal was opened with parent context. | + +Example root category: + +```ts +{ + name: "Summer essentials", + handle: "summer-essentials", + description: { + text: "Products for warm weather.", + html: "

Products for warm weather.

", + json: { blocks: [...] } + }, + mediaFileIds: ["file_1", "file_2"] +} +``` + +Example subcategory from payload: + +```ts +{ + name: "Headphones", + handle: "headphones", + parentId: "gid://shopana/Category/..." +} +``` + +## Target File Structure + +```text +admin/src/domains/inventory/categories/ + graphql/ + fragments.ts + queries.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-categories.ts + use-create-category.ts + index.ts + mappers/ + category-create.mapper.ts + category-errors.mapper.ts + index.ts + modals/ + create-category-modal/ + create-category-modal.tsx + general-section.tsx + media-section.tsx + schema.ts + types.ts + index.ts + category-modal/ + ... + modals.ts + page/ + page.tsx +``` + +## GraphQL Layer + +### Fragments + +Add a mutation result fragment compatible with the categories table. + +```ts +export const CATEGORY_MUTATION_RESULT_FRAGMENT = gql` + fragment CategoryMutationResultFields on Category { + id + name + handle + isPublished + publishedAt + productsCount + depth + path + createdAt + updatedAt + media { + sortIndex + file { + id + url + originalName + mimeType + altText + } + } + parent { + id + name + handle + } + } +`; +``` + +### Mutation + +Add `admin/src/domains/inventory/categories/graphql/mutations.ts`. + +```ts +export const CATEGORY_CREATE_MUTATION = gql` + mutation CategoryCreate($input: CategoryCreateInput!) { + catalogMutation { + categoryCreate(input: $input) { + category { + ...CategoryMutationResultFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${CATEGORY_MUTATION_RESULT_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; +``` + +Do not import product module GraphQL internals from categories. If `UserErrorFields` is not shared, duplicate the small fragment locally or move it to a shared GraphQL location in a separate cleanup. + +### Operation Types + +Extend `admin/src/domains/inventory/categories/graphql/operation-types.ts`. + +```ts +import type { + ApiCatalogMutation, + ApiCategoryCreateInput, + ApiCategoryCreatePayload, +} from "@/graphql/types"; + +export interface CategoryCreateMutationData { + catalogMutation: Pick & { + categoryCreate: ApiCategoryCreatePayload; + }; +} + +export interface CategoryCreateMutationVariables { + input: ApiCategoryCreateInput; +} +``` + +Export mutations from `graphql/index.ts`. + +## Mapper + +Create `admin/src/domains/inventory/categories/mappers/category-create.mapper.ts`. + +```ts +interface CreateCategoryInput { + name: string; + handle: string; + description: OutputData | null; + media: ApiFile[]; + parentId?: string | null; +} +``` + +Mapper responsibilities: + +- `prepareRichText(value)` converts EditorJS data to `ApiRichTextInput` with `renderContent`. +- `prepareMediaFileIds(media)` returns `string[] | undefined`. +- `prepareCategoryPayload(input)` returns `ApiCategoryCreateInput`. +- Do not include `excerpt`, `seo`, or `publish`. + +## Error Mapping + +Create `admin/src/domains/inventory/categories/mappers/category-errors.mapper.ts`. + +Fields: + +```ts +type CategoryFormErrorField = + | "name" + | "handle" + | "description" + | "media"; +``` + +Aliases: + +```ts +const FIELD_ALIASES: Record = { + name: "name", + handle: "handle", + description: "description", + media: "media", + mediaFileIds: "media", +}; +``` + +Behavior should mirror `mapProductUserErrorsToFormErrors`. + +## Hook Contract + +Create `admin/src/domains/inventory/categories/hooks/use-create-category.ts`. + +```ts +interface CreateCategoryResult { + category: ApiCategory | null; + userErrors: ApiGenericUserError[]; +} + +interface UseCreateCategoryReturn { + createCategory: ( + input: CreateCategoryInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} +``` + +Rules: + +- The hook owns `useMutation(CATEGORY_CREATE_MUTATION)`. +- It calls `prepareCategoryPayload(input)`. +- It unwraps `data.catalogMutation.categoryCreate`. +- It returns `{ category, userErrors }`. +- Runtime errors become `[{ code: "UNEXPECTED_ERROR", message }]`, matching product create. +- For list freshness, prefer caller-driven refresh: pass `onCreated` from `CategoriesPage` and call `refetch()` after success. + +## Modal Submit Flow + +```text +User clicks Create + ↓ +react-hook-form + Zod validation + ↓ +createCategory({ + ...formValues, + parentId: payload.parentId ?? null +}) + ↓ +prepareCategoryPayload(input) + ↓ +catalogMutation.categoryCreate(input) + ↓ +if userErrors: + map field errors with category-errors.mapper + message.error(first user error) + keep modal open +else: + message.success("Category created successfully") + payload.onCreated?.(category) + pop() +``` + +Submit button: + +- uses `ModalHeader.submitButtonProps`; +- shows `loading: isSubmitting`; +- closes only on successful `category` with no `userErrors`. + +## Validation + +Create `schema.ts` close to product create schema style. + +Rules: + +- `name`: required, max 255. +- `handle`: required, max 255, product slug regex. +- `description`: `OutputData | null`. +- `media`: `ApiFile[]`. + +Duplicate handle validation should come from API `userErrors`, not from client-only checks. + +## Modal Registration And Page Integration + +Add a create modal type: + +```ts +export const CATEGORY_CREATE_MODAL_TYPE = "category-create"; +``` + +Add payload typing: + +```ts +export interface ICreateCategoryModalPayload extends IModalStackPayload { + parentId?: string | null; + onCreated?: (category: ApiCategory) => void; +} +``` + +Add hook: + +```ts +export const useCreateCategoryModal = + createModalStackHook(CATEGORY_CREATE_MODAL_TYPE); +``` + +Register in `admin/src/domains/modals.tsx`: + +```text +type: "category-create" +component: "@/domains/inventory/categories/modals/create-category-modal" +``` + +Replace disabled Categories page action: + +```text +Create button +└── openCreateCategoryModal({ + onCreated: () => refetch() + }) +``` + +Future subcategory action: + +```text +openCreateCategoryModal({ + parentId: currentCategory.id, + onCreated: refreshCategoryDetails +}) +``` + +## Implementation Phases + +1. Add GraphQL mutation, mutation fragment, operation types, and exports. +2. Add category create mapper and category error mapper. +3. Add `useCreateCategory`. +4. Build modal files: + - `create-category-modal.tsx`; + - `general-section.tsx`; + - `media-section.tsx`; + - `schema.ts`; + - `types.ts`. +5. Register `category-create` modal and typed hook. +6. Enable the Categories page `Create` button and refresh the list after success. + +## Manual Verification + +Do not run tests or `tsc` per project instructions. + +Manual checks: + +- Open Categories page. +- Click Create. +- Confirm modal visually matches product create: + - same header shape; + - same body width/spacing; + - same `Paper` sections; + - same handle input style; + - same media gallery behavior; + - no category-only settings section; + - no publish/status control; + - no category-specific instructional text. +- Create category with only `name` and `handle`. +- Upload/reorder media and confirm `mediaFileIds` are sent in order. +- Submit duplicate handle and confirm API error maps to `handle`. +- Confirm categories table refetches after success. + +Use `npm run build` only if a fresh compiled version is needed. + +## Acceptance Criteria + +- `CreateCategoryModal` uses the same visual composition as `CreateProductModal`. +- Main body sections are only `General` and `Media`. +- `General` mirrors product `GeneralSection` layout and behavior. +- `Media` reuses `EntityMediaGallery` like product create. +- There is no `Category settings` section. +- There is no publish/status switch. +- There is no draft/publish explanatory copy. +- There are no standalone SEO or Preview sections. +- Submit calls `catalogMutation.categoryCreate` through module-local category GraphQL/hook files. +- Form data maps to `ApiCategoryCreateInput` through a category mapper. +- API `userErrors` map to form fields through a category error mapper. +- Categories list refreshes after successful creation. +- No product module internals are imported by category GraphQL/hooks/mappers. diff --git a/admin/docs/category-listing-preview-modal-plan.md b/admin/docs/category-listing-preview-modal-plan.md new file mode 100644 index 000000000..556d82b64 --- /dev/null +++ b/admin/docs/category-listing-preview-modal-plan.md @@ -0,0 +1,696 @@ +# План: Listing preview modal для категории в Admin UI + +## Цель + +Добавить в `CategoryDetailsModal` storefront-like preview страницы категории. В секции `Products` кнопка `Preview` открывает отдельную модалку `ListingPreviewModal`, которая показывает, как категория будет выглядеть в storefront: заголовок категории, total count, сортировка, фасеты с counts, сетка продуктов, pagination. + +Preview должен грузить данные через `listingQuery.listing` из `listing` service, а не через текущий `catalogQuery.category.listing`. Текущий `Category.listing` остается для admin-таблицы assigned products и операций управления категорией. + +## Контекст проекта + +- Admin frontend: `admin/`. +- Category details modal: + - `admin/src/domains/inventory/categories/modals/category-modal/category-modal.tsx` + - `admin/src/domains/inventory/categories/components/category-details-card/category-details-card.tsx` + - `admin/src/domains/inventory/categories/components/category-details-card/sections/products-section.tsx` +- Modal stack: + - `admin/src/domains/inventory/categories/modals.ts` + - `admin/src/domains/modals.tsx` +- Существующая products-секция грузит admin connection через: + - `admin/src/domains/inventory/categories/graphql/queries.ts` + - `CATEGORY_PRODUCTS_QUERY` + - `admin/src/domains/inventory/categories/hooks/use-category-products.ts` +- Новый preview должен использовать listing service contract: + - `services/listing/src/api/graphql-admin/schema/base.graphql` + - `services/listing/src/api/graphql-admin/schema/listing.graphql` + - `services/listing/docs/listing-admin-schema-plan.ru.md` + +## Важное ограничение по schema/codegen + +В текущем `admin/schema.graphql` найден `Category.listing`, но не найден `listingQuery.listing`. При реализации сначала нужно убедиться, что admin supergraph/schema уже включает listing service admin SDL: + +```graphql +type Query { + listingQuery: ListingQuery! +} + +type ListingQuery { + listing( + first: Int + after: String + scope: ListingScopeInput + query: String + locale: LocaleCode + currency: CurrencyCode + facets: [ListingProductFilter!] + orderBy: ListingOrderByInput + ): ListingConnection! +} +``` + +Если generated types в `admin/src/graphql/types.ts` еще не содержат `ApiListingScopeInput`, `ApiListingProductFilter`, `ApiListingFacet`, `ApiListingSortBy`, `ApiListingSortDirection`, нужно сначала обновить composed schema/codegen через project workflow. Не писать временные ad hoc типы вместо generated API types. + +## User flow + +1. Пользователь открывает category details modal. +2. В секции `Products` рядом с sort и assign появляется кнопка `Preview`. +3. Нажатие открывает `ListingPreviewModal`. +4. Модалка делает первый запрос: + - `scope: { kind: CATEGORY, categoryId }` + - `first: 24` + - `facets: []` + - `orderBy`: default sort категории. +5. Пользователь меняет sort, кликает facet values, вводит price range или availability. +6. Модалка повторно запрашивает `listingQuery.listing` с выбранными `facets` и сброшенным cursor. +7. Pagination `Next/Previous` меняет cursor и оставляет sort/facets без изменений. +8. Empty state показывает, что по текущим фильтрам нет продуктов. + +## Desktop wireframe + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Listing preview: Women / Dresses [Esc] │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ Storefront preview │ +│ │ +│ Women / Dresses │ +│ 128 products Sort: [Newest first v]│ +│ │ +│ ┌──────────────────────┐ ┌───────────────────────────────────────────────┐ │ +│ │ Filters │ │ Showing 1-24 of 128 │ │ +│ │ │ │ │ │ +│ │ Availability │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ +│ │ ☑ In stock 96 │ │ │ image │ │ image │ │ image │ │ image │ │ │ +│ │ ☐ Out of stock 32 │ │ ├────────┤ ├────────┤ ├────────┤ ├────────┤ │ │ +│ │ │ │ │ Dress │ │ Shirt │ │ Skirt │ │ Coat │ │ │ +│ │ Price │ │ │ ₴1,200 │ │ ₴890 │ │ ₴740 │ │ ₴2,400 │ │ │ +│ │ [ 500 ] - [ 2500 ] │ │ │ In stock││ In stock││ Draft ││ Sold out│ │ │ +│ │ │ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │ +│ │ Size │ │ │ │ +│ │ ☐ XS 12 │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ +│ │ ☑ S 34 │ │ │ image │ │ image │ │ image │ │ image │ │ │ +│ │ ☐ M 58 │ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │ +│ │ ☐ L 27 │ │ │ │ +│ │ │ │ [Prev] Page 1 [Next] │ │ +│ │ Color │ └───────────────────────────────────────────────┘ │ +│ │ ● Black 18 │ │ +│ │ ○ White 15 │ │ +│ │ ○ Blue 9 │ │ +│ └──────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +## Mobile/narrow modal wireframe + +Для narrow viewport внутри модалки preview должен перейти в один столбец. Фасеты не должны занимать отдельный левый rail; открывать их через drawer/collapse внутри модалки. + +```text +┌────────────────────────────────────┐ +│ Listing preview [x] │ +├────────────────────────────────────┤ +│ Women / Dresses │ +│ 128 products │ +│ │ +│ [Filters 3] [Sort: Newest first v] │ +│ │ +│ Showing 1-12 of 128 │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ image │ │ image │ │ +│ │ Dress │ │ Shirt │ │ +│ │ ₴1,200 │ │ ₴890 │ │ +│ │ In stock │ │ Sold out │ │ +│ └──────────────┘ └──────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ image │ │ image │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ [Prev] Page 1 [Next] │ +└────────────────────────────────────┘ + +Filters drawer: +┌────────────────────────────────────┐ +│ Filters [x] │ +│ Availability │ +│ ☑ In stock 96 │ +│ ☐ Out of stock 32 │ +│ Size │ +│ ☐ XS 12 │ +│ ☑ S 34 │ +│ [Clear all] [Apply] │ +└────────────────────────────────────┘ +``` + +## Data contract + +### Query + +Добавить новую операцию в модуль categories, потому preview открывается из category details: + +`admin/src/domains/inventory/categories/graphql/queries.ts` + +```graphql +query CategoryListingPreview( + $categoryId: ID! + $first: Int + $after: String + $query: String + $locale: LocaleCode + $currency: CurrencyCode + $facets: [ListingProductFilter!] + $orderBy: ListingOrderByInput +) { + listingQuery { + listing( + first: $first + after: $after + scope: { kind: CATEGORY, categoryId: $categoryId } + query: $query + locale: $locale + currency: $currency + facets: $facets + orderBy: $orderBy + ) { + edges { + cursor + node { + id + ... on Product { + title + handle + isPublished + media { + sortIndex + file { + id + url + alt + } + } + priceRange { + minPrice { + amount + currencyCode + } + maxPrice { + amount + currencyCode + } + } + # Если availability уже есть в canonical Product/Bundle admin schema, + # добавить его здесь. Если нет, нужен backend contract ниже. + } + ... on Bundle { + title + handle + isPublished + media { + sortIndex + file { + id + url + alt + } + } + priceRange { + minPrice { + amount + currencyCode + } + maxPrice { + amount + currencyCode + } + } + } + } + } + facets { + id + label + type + uiType + values { + id + label + count + selected + input + swatch { + id + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +} +``` + +### Availability + +Карточка продукта должна показывать availability. Есть два допустимых варианта: + +1. Если composed admin schema уже дает product-level availability для listing nodes, использовать canonical поле продукта напрямую. +2. Если поля нет, добавить его в backend contract до UI-реализации. Не выводить availability из `isPublished`: это разные состояния. Временный fallback допустим только как явное `Unknown availability`, а не как fake stock status. + +Ожидаемый UI copy: + +- `In stock` +- `Low stock`, если backend отдаст threshold/status. +- `Sold out` +- `Unavailable`, если item не sellable. +- `Draft`, если item не published, отдельно от stock. + +## Frontend структура + +```text +admin/src/domains/inventory/categories/ + graphql/ + queries.ts # CATEGORY_LISTING_PREVIEW_QUERY + operation-types.ts # CategoryListingPreviewQueryData/Variables + hooks/ + use-category-listing-preview.ts # listingQuery.listing hook + index.ts + modals/ + listing-preview-modal/ + listing-preview-modal.tsx + listing-preview-modal.styles.ts + listing-preview-facets.tsx + listing-preview-grid.tsx + listing-preview-product-card.tsx + listing-preview-sort.tsx + listing-preview-pagination.tsx + types.ts # UI-local state only + index.ts + components/ + category-details-card/ + sections/products-section.tsx # adds Preview button prop/callback + hooks/use-category-modals.ts # opens preview modal + modals.ts # modal type + payload + hook +``` + +## Hook contract + +`useCategoryListingPreview(categoryId, options)`: + +- owns Apollo `useQuery`; +- returns unwrapped API data, not UI view models; +- uses generated API types from `@/graphql/types`; +- uses `cache-and-network`; +- skips request until modal is open and `categoryId` exists; +- preserves `previousData` while changing sort/facets so grid does not flash empty. + +Suggested return: + +```ts +interface UseCategoryListingPreviewReturn { + items: ApiListing[]; + facets: ApiListingFacet[]; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} +``` + +If generated `ApiListing` is still the old catalog shape, do not reuse it blindly. Use operation-derived types based on generated schema types in `operation-types.ts`, according to `knowledge/vault/patterns/admin-graphql-layer.md`. + +## Modal payload + +`admin/src/domains/inventory/categories/modals.ts` + +```ts +export const CATEGORY_LISTING_PREVIEW_MODAL_TYPE = "category-listing-preview"; + +export interface ICategoryListingPreviewModalPayload extends IModalStackPayload { + category: ApiCategory; +} +``` + +Add module augmentation and hook: + +```ts +export const useCategoryListingPreviewModal = createModalStackHook( + CATEGORY_LISTING_PREVIEW_MODAL_TYPE, +); +``` + +Register lazy modal in `admin/src/domains/modals.tsx`. + +## Products section changes + +`ProductsSection` gets a new prop: + +```ts +onPreview?: () => void; +``` + +Header actions become: + +```text +[Preview] [Sort icon] [Assign] +``` + +Button behavior: + +- icon: `EyeOutlined`; +- disabled only if category id is missing, not when `productsCount` is 0; +- preview can open for empty categories to show storefront empty state; +- `data-testid="category-products-preview-button"`. + +`CategoryDetailsCard` passes `modals.previewListing`. + +`useCategoryModals` imports `useCategoryListingPreviewModal` and returns: + +```ts +const previewListing = useCallback(() => { + openListingPreviewModal({ category }); +}, [category, openListingPreviewModal]); +``` + +## Modal UI behavior + +### Layout + +- Modal width: `min(1180px, calc(100vw - 48px))`. +- Body height: `min(760px, calc(100vh - 160px))`. +- Header: title `Listing preview: {category.name}`. +- Body: unframed storefront preview surface, not card-inside-card. +- Left facets rail on desktop: 240-280px. +- Product grid: responsive CSS grid: + - desktop: 4 columns; + - medium: 3 columns; + - narrow: 2 columns; + - very narrow: 1 column if needed. + +### Header area inside preview + +Show: + +- breadcrumb-like category path if `ancestors` are loaded in `ApiCategory`; +- category name as storefront title; +- `totalCount` as `{n} products`; +- sort select; +- selected facets summary chips: + - label: `{facet.label}: {value.label}`; + - close icon removes that filter; + - `Clear all` appears when any filter is selected. + +### Facets + +Render by `ListingFacet.uiType`: + +- `CHECKBOX`: multi-select checkbox list with counts. +- `RADIO`: one selected value per facet. +- `DROPDOWN`: select control; counts in option label. +- `RANGE`: price min/max inputs, counts remain visible if service returns values. +- `BOOLEAN`: switch or checkbox row. + +Each value displays: + +```text +[control] Label count +``` + +Rules: + +- do not hide `count = 0`, but disable value unless it is selected; +- selected value remains clickable to remove; +- use `values[].input` as source of truth for next `facets` request; +- do not reconstruct product/vendor/tag filters manually if `input` exists; +- facet selection resets pagination cursor to first page. + +### Sorting + +Use listing service enum, not old `ListingOrderField`: + +```text +Manual +Relevance +Newest first +Name A to Z +Name Z to A +Price low to high +Price high to low +``` + +Mapping: + +- Manual: `{ by: MANUAL }` +- Relevance: `{ by: RELEVANCE }` +- Newest first: `{ by: NEWEST, direction: desc }` +- Name A to Z: `{ by: NAME, direction: asc }` +- Name Z to A: `{ by: NAME, direction: desc }` +- Price low to high: `{ by: PRICE, direction: asc }` +- Price high to low: `{ by: PRICE, direction: desc }` + +Initial sort derives from `category.defaultSort` / `category.defaultSortDirection`, but maps to listing service sort enum. If mapping is impossible, fallback to `{ by: MANUAL }` and show no warning. + +### Product card + +Simple card: + +```text +┌────────────────────┐ +│ image │ +├────────────────────┤ +│ Product title │ +│ ₴1,200 │ +│ In stock │ +└────────────────────┘ +``` + +Fields: + +- image: first media by `sortIndex`; fallback placeholder icon; +- title: product title, line-clamped to 2 lines; +- price: + - same min/max: `₴1,200`; + - range: `₴900 - ₴1,400`; + - missing: `No price`; +- availability: + - use canonical availability when available; + - show draft status as secondary tag if `isPublished === false`; + - do not add product actions in preview. + +### Pagination + +Use cursor pagination, not page-number pagination. + +State: + +- `first = 24` desktop, `first = 12` narrow if desired; +- `after = pageInfo.endCursor` for next; +- keep a local cursor stack for previous page because listing service currently reports `hasPreviousPage: false` in resolver; +- reset cursor stack on sort/facet/query/category change. + +Display: + +```text +Showing 1-24 of 128 [Previous] [Next] +``` + +If exact range is hard with cursor stack, use loaded page index: + +```text +Page 1 · 24 of 128 loaded +``` + +Do not claim an exact range if previous-page support is not available from backend. + +## State model + +UI-local state in `listing-preview-modal/types.ts`: + +```ts +interface ListingPreviewState { + selectedFacetInputs: ApiListingProductFilter[]; + orderBy: ApiListingOrderByInput; + after: string | null; + cursorStack: string[]; + query: string; +} +``` + +Selection helpers: + +- `isFacetValueSelected(value)` can rely on `value.selected` from API. +- `toggleFacetValue(value)`: + - if selected, remove matching `value.input` from `selectedFacetInputs`; + - if not selected, add `value.input`; + - for radio facets, replace other inputs from same facet group; + - reset `after` and `cursorStack`. +- Matching should use stable serialized `input`, preferably deterministic JSON stringification for plain GraphQL input objects. + +## Loading, empty, error states + +- Initial loading: skeleton for facets rail and product grid. +- Refetch while changing controls: keep previous grid, show small spinner near sort/total. +- Empty listing: + +```text +No products match this preview +Try removing filters or changing the sort. +``` + +- Error: + - Ant `Alert` at top of modal body; + - keep controls visible if previous data exists; + - expose `Retry`. + +## Accessibility and test ids + +Minimum test ids: + +- `category-products-preview-button` +- `category-listing-preview-modal` +- `category-listing-preview-sort` +- `category-listing-preview-total-count` +- `category-listing-preview-facet-{facet.id}` +- `category-listing-preview-facet-value-{value.id}` +- `category-listing-preview-clear-filters` +- `category-listing-preview-product-card-{id-or-handle}` +- `category-listing-preview-pagination-next` +- `category-listing-preview-pagination-prev` + +Controls need labels: + +- sort select label: `Sort products`; +- filters drawer button label: `Open filters`; +- facet controls include `{facet label}: {value label}, {count} products`. + +## Implementation plan + +### 1. Schema readiness + +1. Verify admin generated schema includes `listingQuery.listing`. +2. If missing, update federation/admin schema composition for listing service and run project codegen workflow. +3. Confirm generated types include: + - `ApiListingScopeInput` + - `ApiListingScopeKind` + - `ApiListingProductFilter` + - `ApiListingOrderByInput` + - `ApiListingSortBy` + - `ApiListingSortDirection` + - `ApiListingFacet` + - `ApiListingFacetValue` +4. Confirm canonical `Product`/`Bundle` fields needed by cards are selectable through listing query fragments. +5. Confirm availability field exists or add backend contract before frontend card finalization. + +### 2. GraphQL operation + +1. Add `CATEGORY_LISTING_PREVIEW_QUERY` in categories `graphql/queries.ts`. +2. Add operation data/variables types in `graphql/operation-types.ts`, derived from generated schema types. +3. Export through existing `graphql/index.ts`. +4. Keep old `CATEGORY_PRODUCTS_QUERY` unchanged. + +### 3. Query hook + +1. Add `hooks/use-category-listing-preview.ts`. +2. Use Apollo `useQuery` or existing relay helper only if it supports extra `facets` data. +3. Return `items`, `facets`, `totalCount`, `pageInfo`, `loading`, `error`, `refetch`. +4. Preserve previous data during refetch. +5. Export from `hooks/index.ts`. + +### 4. Modal registration + +1. Add modal type and payload in `categories/modals.ts`. +2. Add `useCategoryListingPreviewModal`. +3. Add `modals/listing-preview-modal/index.ts`. +4. Register lazy modal in `admin/src/domains/modals.tsx`. + +### 5. Open action from products section + +1. Add `onPreview` prop to `ProductsSection`. +2. Add `Preview` button with `EyeOutlined`. +3. Add `previewListing` to `useCategoryModals`. +4. Pass `modals.previewListing` from `CategoryDetailsCard`. + +### 6. Build listing preview modal + +1. Implement `ListingPreviewModal`. +2. Add local state for sort, facets, cursor stack, optional query. +3. Wire hook variables from state: + - `categoryId` + - `first` + - `after` + - `facets` + - `orderBy` +4. Render header, total count, selected filters, sort. +5. Render desktop facets rail and narrow filters drawer/collapse. +6. Render product grid and cards. +7. Render cursor pagination. + +### 7. Styling + +1. Add `listing-preview-modal.styles.ts` using existing project styling approach. +2. Keep modal preview visually close to storefront but still inside admin: + - neutral page surface; + - restrained borders; + - product images with stable aspect ratio; + - no nested cards for whole page sections. +3. Ensure text does not overflow product cards or buttons. +4. Ensure fixed card image aspect ratio prevents layout shifts. + +### 8. Verification + +Project rule: do not run `test` or `tsc` for verification. Run build only when a new code version needs verification. + +Manual checks: + +1. Open category details modal. +2. Click `Preview`. +3. Confirm initial loading state. +4. Confirm total count equals listing query result. +5. Confirm facets show counts. +6. Select a facet value: + - product grid updates; + - selected count/chip updates; + - pagination resets. +7. Change sort: + - order updates; + - filters remain selected. +8. Click next page: + - products update; + - next disabled at end. +9. Clear filters: + - all selected states disappear; + - total count returns to unfiltered count. +10. Check empty category and no-match filters. +11. Check narrow viewport layout. + +## Acceptance criteria + +- `ProductsSection` has a `Preview` button. +- Button opens `ListingPreviewModal` from the category details modal. +- Modal uses `listingQuery.listing` with `scope.kind = CATEGORY`. +- Modal shows: + - category title/path; + - total count; + - storefront-like product grid; + - simple product cards with image, title, price, availability; + - sort control; + - facets with value counts; + - selected filters and clear all; + - cursor pagination. +- Facet clicks send `values[].input` back as `facets`. +- Sort uses listing service `ListingOrderByInput`. +- Existing admin products table remains on `Category.listing` or its current replacement and does not inherit preview state. +- No API-output UI view models are introduced; components consume generated API-shaped data or operation-derived generated types. +- Empty, loading, refetching, and error states are handled. +- Narrow modal layout is usable. + +## Open questions + +1. Какое canonical поле должно использоваться для product availability в admin GraphQL: product-level aggregate availability, selected default variant availability или listing service sellable status? +2. Должен ли preview показывать bundles вместе с products? Listing service возвращает mixed `Listing` interface (`Product | Bundle`), а user request говорит “продукты”. Если bundles возможны в категории, UI должен либо поддержать bundle cards, либо backend/query должен ограничить scope до products. +3. Нужен ли search box внутри category preview, или preview должен показывать только category browsing без локального search? +4. Нужно ли использовать текущую store locale/currency из admin context явно, или полагаться на defaults listing resolver? +5. Нужна ли точная previous-page навигация от backend? Сейчас listing resolver в service возвращает `hasPreviousPage: false`, поэтому frontend может сделать только cursor stack для текущей сессии. diff --git a/admin/docs/customer-details-card-modal-redesign-wireframes.md b/admin/docs/customer-details-card-modal-redesign-wireframes.md new file mode 100644 index 000000000..201130f42 --- /dev/null +++ b/admin/docs/customer-details-card-modal-redesign-wireframes.md @@ -0,0 +1,1175 @@ +# Customer Details: redesign details card и section edit modals + +## Цель + +Спроектировать новый UI для `CustomerDetailsCard`, details modal и связанных модалок редактирования секций. Новый экран должен выглядеть как часть той же системы, что `ProductDetailsCard`, `CategoryDetailsCard` и спроектированный `ReviewDetailsCard`: компактная summary-карточка сверху, последовательные `Paper`-секции, локальные действия секций и предсказуемый Modal Stack. + +Документ описывает presentation и interaction design. Он сохраняет существующий `Customer`, `CustomerUpdateInput`, lifecycle workflows и modal flow; отдельная customer page и новый GraphQL contract не предлагаются. + +Референсы в текущем Admin UI: + +- `admin/src/domains/customers/all-customers/components/customer-details-card`; +- `admin/src/domains/customers/all-customers/modals/customer-modal`; +- `admin/src/domains/customers/all-customers/modals/customer-edit-modal`; +- `admin/src/domains/customers/lifecycle/modals/merge-modal`; +- `admin/src/domains/customers/lifecycle/modals/data-request-modal`; +- `admin/src/domains/customers/all-customers/picker/customer-picker-config`; +- `admin/src/domains/inventory/products/components/product-info-header`; +- `admin/src/domains/inventory/categories/components/category-info-header`; +- `admin/src/domains/inventory/components/entity-details-sections`; +- `admin/src/layouts/modals`; +- `admin/src/ui-kit/paper`; +- `admin/src/ui-kit/kpi-tile`; +- `admin/src/ui-kit/copyable-chip`; +- `admin/src/shared/components/entity-picker-modal`. + +## Что меняется относительно текущего UI + +Текущий экран уже использует `Paper`, но остаётся длинной технической карточкой: header не повторяет устойчивую Product/Category композицию, KPI вынесены в отдельный тяжёлый блок, почти каждая секция построена на одинаковом `Descriptions`, audit metadata занимает основной scroll, а один `CustomerEditModal` одновременно гидратирует все editable collections независимо от выбранной секции. + +В новом варианте: + +1. Details modal получает стабильный заголовок `Customer details`; display name живёт только в summary-card. +2. Верхняя карточка повторяет композицию Product/Category info header: lifecycle/meta, identity, contact summary, actions, divider и четыре реальных KPI. +3. В начале показываются данные для ежедневной работы: кто этот клиент, как с ним связаться, его value и последние commerce events. +4. `Activity & value` не остаётся второй KPI-карточкой: основные metrics находятся только в header, а section показывает недублируемые breakdown и timestamps. +5. `Company` объединяется с personal profile в одну reading section, но сохраняет отдельный edit flow и отдельный API subtree. +6. `Notes & moderation` и `Lifecycle` объединяются в `Account & notes`, чтобы account decision, blocked reason и внутренний контекст читались вместе. +7. `Audit metadata` удаляется из основного scroll и открывается через `View technical metadata` из header overflow. +8. Все секционные действия используют существующий `EditAction` с `⋯`; primary-кнопка остаётся только в modal header. +9. Монолитный `CustomerEditModal` разделяется по `CustomerUpdateInput` subtrees. Изменение профиля больше не гидратирует и не пересылает addresses, consents, classification или tax collections. +10. Address и tax collection editors используют parent draft + nested item modal, чтобы длинные inline forms не превращали экран в анкету. +11. Merge и privacy request остаются отдельными workflow modals и переиспользуют существующий Customer Picker. +12. Empty/loading/error states используют те же визуальные принципы, что Product/Category/Review details. + +## Визуальные правила + +- Контент details и edit modals использует стандартный `ModalLayout`, `max-width: 800px`. +- Между карточками — `12px`, внутри `Paper` — текущий token-based padding. +- Одна карточка отвечает на один пользовательский вопрос: «кто этот клиент», «какова его commerce activity», «куда доставлять», «можно ли отправлять marketing», «к каким группам он относится». +- `Typography.Title level={3}` используется один раз — в `CustomerInfoHeader`. +- `PaperHeader` используется для всех секций; локальное действие находится справа. +- Цвет используется только семантически: lifecycle, verification, consent, address validation, tax status и errors. +- Системные ID показываются через `CopyableChip` или `Typography.Text copyable`, а не как обычный длинный текст. +- Не использовать отдельные декоративные градиенты, большие цветные hero-блоки и новые card primitives. +- Не использовать `Descriptions` как единственный layout для каждой секции; списки, компактные rows и semantic panels должны соответствовать характеру данных. +- Тексты controls остаются на английском, как в существующем Admin UI. +- Email, phone, tax identifiers и address values не должны попадать в decorative tooltip, `title` attribute или analytics event labels. + +## Information architecture + +```text +Customer details modal +├── CustomerInfoHeader +│ ├── lifecycle status + created/updated/source meta +│ ├── avatar + display name + account status +│ ├── email / phone verification summary + customer ID +│ └── orders / net spent / average order / returns KPI +├── CustomerProfileSection +│ └── personal profile + company +├── CustomerOrderActivitySection +│ └── outcome breakdown + money breakdown + first/last activity +├── CustomerAddressesSection +├── CustomerConsentsSection +├── CustomerClassificationSection +│ └── groups + tags + rule/manual segments +├── CustomerTaxSection +│ └── identifiers + exemptions +└── CustomerAccountSection + └── lifecycle decision + merchant/moderation notes + terminal events +``` + +Порядок намеренный: оператор может идентифицировать клиента и оценить его relationship с магазином до прокрутки через classification, tax и системные lifecycle details. + +## Modal Stack + +```text +Customers page +└── Customer details level 0 + ├── Edit personal profile level 1 + ├── Edit contact details level 1 + ├── Edit company level 1 + ├── Manage addresses level 1 + │ └── Add / edit address level 2 + ├── Edit marketing consents level 1 + ├── Edit customer groups level 1 + ├── Edit customer tags level 1 + ├── Edit manual segments level 1 + ├── Change customer status level 1 + ├── Edit merchant note level 1 + ├── Edit moderation note level 1 + ├── Manage tax identifiers level 1 + │ └── Add / edit tax identifier level 2 + ├── Manage tax exemptions level 1 + │ ├── Add / edit tax exemption level 2 + │ └── Media picker / upload certificate level 2 + ├── View technical metadata level 1 + ├── Merge customer level 1 + │ └── Customer picker level 2 + └── New privacy request level 1 +``` + +После сохранения дочерняя modal вызывает `onSaved`, details query refetch выполняется до закрытия child modal, затем пользователь возвращается к актуальному Customer Details. Отмена nested item modal или picker не меняет draft родительской формы. + +Рекомендуемые section keys: + +```ts +type CustomerEditSection = + | "profile" + | "contact" + | "company" + | "addresses" + | "consents" + | "groups" + | "tags" + | "segments" + | "status" + | "note" + | "moderation" + | "taxIdentifiers" + | "taxExemptions"; +``` + +Текущие composite keys `classification`, `notes` и `tax` удаляются после миграции callers на независимые flows. + +## Customer Details Modal + +### Полный wireframe + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Customer details │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [API error alert — only when present] │ +│ │ +│ ┌─ CustomerInfoHeader ───────────────────────────────────────────────┐ │ +│ │ [ACTIVE ✓] Customer since Jul 2024 · Updated Jul 16 · Admin [⋯] │ │ +│ │ │ │ +│ │ [MJ] Maria Johnson │ │ +│ │ maria@example.com [verified] · +1 202 555 0147 │ │ +│ │ [Registered] [ID 01J8A7C2] │ │ +│ │ │ │ +│ │ ───────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Orders │ │ Net spent │ │ Avg. order │ │ Returns │ │ │ +│ │ │ 18 │ │ $2,460.00 │ │ $151.25 │ │ 1 │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Customer profile ─────────────────────────────────────────── [⋯] ┐ │ +│ │ Personal │ │ +│ │ Maria Elena Johnson · English (en) · Born Apr 12, 1991 │ │ +│ │ Gender: Female │ │ +│ │ │ │ +│ │ Company │ │ +│ │ Northstar Studio · Purchasing manager │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Order activity ──────────────────────────────────────────────────┐ │ +│ │ Completed 16 · Cancelled 2 Gross $2,750 · Refunded $290 │ │ +│ │ │ │ +│ │ First order Jul 4, 2024 [Order ID copy] │ │ +│ │ Last order Jul 12, 2026 [Order ID copy] │ │ +│ │ Last checkout Jul 15, 2026 · Last activity Jul 16, 2026 │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Addresses (2) ──────────────────────────────────────────── [⋯] ┐ │ +│ │ ┌────────────────────────────┐ ┌────────────────────────────┐ │ │ +│ │ │ Home [Shipping] [Valid] │ │ Office [Billing] [Unvalid.]│ │ │ +│ │ │ Maria Johnson │ │ Northstar Studio │ │ │ +│ │ │ 1 Main St, Austin, TX... │ │ 18 Market Rd, Boston... │ │ │ +│ │ └────────────────────────────┘ └────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Marketing consents ─────────────────────────────────────── [⋯] ┐ │ +│ │ Email [SUBSCRIBED] maria@example.com · Confirmed opt-in │ │ +│ │ Updated Jul 10 · Latest: pending → subscribed │ │ +│ │ SMS [NOT SUBSCRIBED] +1 202 555 0147 · Unknown │ │ +│ │ Updated Jun 3 │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Classification ─────────────────────────────────────────── [⋯] ┐ │ +│ │ Groups [VIP · primary] [Wholesale] │ │ +│ │ Tags [Newsletter] [High intent] │ │ +│ │ Segments [High lifetime value · Rule] [Austin · Manual] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Tax ────────────────────────────────────────────────────── [⋯] ┐ │ +│ │ Identifiers (1) │ │ +│ │ [VERIFIED] VAT · US · 98-7654321 [Primary] │ │ +│ │ │ │ +│ │ Exemptions (1) │ │ +│ │ [ACTIVE] RESALE · US / TX · Valid through Dec 31, 2026 │ │ +│ │ resale-certificate.pdf │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Account & notes ────────────────────────────────────────── [⋯] ┐ │ +│ │ [✓ Active ][ Disabled ][ Blocked ] read-only │ │ +│ │ │ │ +│ │ Merchant note │ │ +│ │ Prefers delivery after 18:00. │ │ +│ │ │ │ +│ │ Internal moderation note │ │ +│ │ Previous chargeback reviewed and resolved. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### CustomerInfoHeader + +![Customer info header](assets/customer-details-redesign/01-customer-info-header.png) + +Композиция повторяет `ProductInfoHeader` и `CategoryInfoHeader`, но показывает только customer-relevant actions. + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ [ACTIVE ✓] Customer since Jul 2024 · Updated Jul 16 · Admin [⋯] │ +│ │ +│ [MJ] Maria Johnson │ +│ maria@example.com [verified] · +1 202 555 0147 [not verified] │ +│ [Registered] [ID 01J8A7C2] │ +│ │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Orders │ │ Net spent │ │ Avg. order │ │ Returns │ │ +│ │ 18 │ │ $2,460.00 │ │ $151.25 │ │ 1 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +`PaperHeader title`: + +- lifecycle `Tag` с icon и tooltip: + - `ACTIVE` — green, check, `Customer can participate in normal store flows`; + - `DISABLED` — default, pause, `Customer account is disabled by an administrator`; + - `BLOCKED` — red, ban, `Customer is blocked; see account details for the reason`; + - `MERGED` — purple, merge, `Profile was merged into another customer`; + - `REDACTED` — default, lock, `Personal data was redacted by a privacy workflow`; +- compact meta: `Customer since {createdAt} · Updated {updatedAt} · {source}`; +- `source` форматируется human-readable; пустые значения не создают dangling separator; +- `createdByUserId` не показывается как actor name: API не возвращает связанную Admin identity. + +`PaperHeader actions`: + +- text button `LinkOutlined` — copy current Admin URL; +- `Dropdown` с `MoreOutlined`: + +```text +Edit personal profile +Edit contact details +Edit company +Manage addresses +──────────────────────── +Edit marketing consents +Edit customer groups +Edit customer tags +Edit manual segments +──────────────────────── +Change customer status +Edit merchant note +Edit moderation note +Manage tax identifiers +Manage tax exemptions +View technical metadata +──────────────────────── +Merge customer +Create privacy request +──────────────────────── +Delete customer danger +``` + +Dropdown является fallback navigation. Основной путь редактирования — `EditAction` соответствующей секции. + +Title area: + +- `Avatar size={56}` с initials; при отсутствии имени — `UserOutlined`; +- title с ellipsis максимум две строки, fallback `Unnamed customer`; +- email и phone выводятся как copyable text; отсутствующие contacts не создают пустые chips; +- verification icon всегда имеет text tooltip и accessible name `Email verified`, `Email not verified`, `Phone verified` или `Phone not verified`; +- `accountStatus` отображается отдельным neutral `Tag`: `Guest`, `Invited`, `Registered`; +- `CopyableChip label="ID"` показывает короткий display value и копирует полный global ID; +- storefront/open/share actions Product/Category здесь не используются: у Customer нет публичного customer URL. + +KPI panel: + +- `KPITile` для `statistics.ordersCount`; +- `KPITile` для default-currency `monetaryStatistics.netSpentMinor`; +- `KPITile` для default-currency `monetaryStatistics.averageOrderValueMinor`; +- `KPITile` для `statistics.returnsCount`. + +`PeriodSwitch` и trends не используются: API содержит lifetime projections без временных рядов. Нельзя переносить mock trends из Product/Category. + +Денежные значения форматируются только с `useDefaultCurrency()`. Запрещено использовать `monetaryStatistics.edges[0].node.currencyCode` как display source или fallback на первую доступную currency row. + +### CustomerProfileSection + +![Customer profile section](assets/customer-details-redesign/02-customer-profile.png) + +```text +┌─ Customer profile ─────────────────────────────────────────────── [⋯] ┐ +│ Personal │ +│ Maria Elena Johnson │ +│ Born Apr 12, 1991 · Female · English (en) │ +│ │ +│ ─────────────────────────────────────────────────────────────────────── │ +│ │ +│ Company │ +│ Northstar Studio · Purchasing manager │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +- Full name собирается из `prefix`, `firstName`, `middleName`, `lastName`, `suffix` без пустых separators. +- `preferredLocale` выводится через `shopLocales` как human label + code. +- `dateOfBirth` форматируется как date без времени. +- Gender остаётся текстовым значением из API; UI не придумывает enum, которого нет в schema. +- Company block не рендерит отдельную пустую карточку. При отсутствии обоих значений показывается compact empty text `No company information`. +- Email и phone не повторяются: они уже принадлежат header summary. +- Section menu содержит независимые действия `Edit personal profile`, `Edit contact details`, `Edit company`. + +### CustomerOrderActivitySection + +![Customer order activity section](assets/customer-details-redesign/03-order-activity.png) + +```text +┌─ Order activity ────────────────────────────────────────────────────────┐ +│ Order outcomes Money breakdown │ +│ Completed 16 Gross spent $2,750.00 │ +│ Cancelled 2 Refunded $290.00 │ +│ │ +│ ─────────────────────────────────────────────────────────────────────── │ +│ │ +│ First order Jul 4, 2024 [Order ID 8f13… copy] │ +│ Last order Jul 12, 2026 [Order ID a112… copy] │ +│ Last checkout Jul 15, 2026 │ +│ Last activity Jul 16, 2026 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +- Section read-only и не имеет `EditAction`: statistics являются rebuildable projections. +- Header KPI здесь не повторяются. Section показывает только `completedOrdersCount`, `cancelledOrdersCount`, `totalSpentMinor`, `totalRefundedMinor` и timestamps. +- `firstOrderId` и `lastOrderId` показываются как `CopyableChip` рядом с соответствующей датой. +- Не показывать `Open order`: комментарий API прямо указывает, что Orders Admin type не является federation entity. +- Не добавлять recent orders list: `Customer` contract её не возвращает. +- Если `statistics` отсутствует, одна compact empty state объясняет `No customer activity statistics yet`; не рендерится сетка из нулей и dash. +- Если default-currency monetary row отсутствует, money values показывают `—`, а не значения другой currency. + +### CustomerAddressesSection + +![Customer addresses section](assets/customer-details-redesign/04-addresses.png) + +```text +┌─ Addresses (4) ────────────────────────────────────────────────── [⋯] ┐ +│ ┌───────────────────────────────┐ ┌───────────────────────────────┐ │ +│ │ Home [Shipping default] │ │ Office [Billing default] │ │ +│ │ [✓ Valid] │ │ [◷ Unvalidated] │ │ +│ │ │ │ │ │ +│ │ Maria Johnson │ │ Northstar Studio │ │ +│ │ 1 Main St │ │ 18 Market Road │ │ +│ │ Austin, TX 78701, US │ │ Boston, MA 02108, US │ │ +│ │ +1 202 555 0147 │ │ │ │ +│ └───────────────────────────────┘ └───────────────────────────────┘ │ +│ [Show all addresses (4)] │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +- Default shipping и billing addresses идут первыми; далее стабильный API order. +- По умолчанию показываются максимум три address cards; `Show all addresses (N)` раскрывает остальные в текущей details modal. +- Address line формируется без лишних commas из `address1`, `address2`, city, region, postal code и country. +- Country code отображается как country label через `shopCountries`, с code в secondary text. +- Validation status: + - `VALID` — green check; + - `UNVALIDATED` — gold clock; + - `INVALID` — red warning; +- `validatedAt` доступен в tooltip/status detail только когда присутствует. +- Latitude/longitude, address ID и created/updated timestamps не участвуют в основном reading flow. +- Empty state: `No addresses added` + section action `Manage addresses`. +- Единственное действие секции — `Manage addresses`. + +### CustomerConsentsSection + +![Customer marketing consents section](assets/customer-details-redesign/05-marketing-consents.png) + +```text +┌─ Marketing consents ───────────────────────────────────────────── [⋯] ┐ +│ [mail] Email [SUBSCRIBED] │ +│ maria@example.com · Confirmed opt-in · Updated Jul 10 │ +│ Latest transition: Pending → Subscribed · Jul 10 │ +│ ───────────────────────────────────────────────────────────────────── │ +│ [sms] SMS [NOT SUBSCRIBED] │ +│ +1 202 555 0147 · Unknown · Updated Jun 3 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +- Рендерятся только существующие consent records; отсутствие record не приравнивается визуально к `NOT_SUBSCRIBED`. +- Один row показывает channel, semantic state tag, contact point, opt-in level, updated date и latest transition. +- Latest event берётся из уже запрошенного newest-first `events.edges[0]`. +- `SUBSCRIBED` — green; `PENDING` — gold; `UNSUBSCRIBED` — default; `INVALID` — red; `REDACTED` — locked/default. +- `sourceIp`, `userAgent`, `actorId` и raw `evidence` не показываются в основной section. +- Empty state: `No marketing consent records`. +- Единственное действие секции — `Edit marketing consents`. + +### CustomerClassificationSection + +![Customer classification section](assets/customer-details-redesign/06-classification.png) + +```text +┌─ Classification ──────────────────────────────────────────────── [⋯] ┐ +│ Groups │ +│ [VIP · primary] [Wholesale · Manual] │ +│ │ +│ Tags │ +│ [Newsletter] [High intent] │ +│ │ +│ Segments │ +│ [High lifetime value · Rule] [Austin · Manual] │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +- Groups показывают `isPrimary`, assignment source и inactive/expired state. +- Tags показываются простыми chips; assignment audit не перегружает основную строку. +- Segments показывают source: `Manual`, `Rule`, `Import`, `System`. +- Rule/import/system memberships являются read-only. `Edit manual segments` управляет только manual subset. +- Inactive membership остаётся видимым muted row/tag с `Inactive`; оно не маскируется как активное. +- Section menu содержит три независимых действия: `Edit customer groups`, `Edit customer tags`, `Edit manual segments`. +- Для каждого пустого subsection используется secondary text `No groups`, `No tags`, `No segments`, а не три больших `Empty` illustrations. + +### CustomerTaxSection + +![Customer tax section](assets/customer-details-redesign/07-tax.png) + +```text +┌─ Tax ──────────────────────────────────────────────────────────── [⋯] ┐ +│ Identifiers (2) │ +│ [VERIFIED] VAT · US · 98-7654321 [Primary] Verified Jul 2 │ +│ [UNVERIFIED] GST · CA · 812345678 No expiry │ +│ │ +│ ─────────────────────────────────────────────────────────────────────── │ +│ │ +│ Exemptions (1) │ +│ [ACTIVE] RESALE · US / TX · Valid through Dec 31, 2026 │ +│ Wholesale resale certificate · resale-certificate.pdf │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +- Identifier row показывает type, country, copyable value, status, primary marker и validity. +- `normalizedValue` не дублируется рядом с value; это server normalization detail. +- Exemption row показывает code, jurisdiction, reason, status, validity и certificate link. +- Certificate открывается как file URL с `rel="noreferrer"`; raw `certificateFileId` пользователю не показывается. +- Status colors используются семантически и одинаково в details/edit flows. +- Section menu содержит `Manage tax identifiers` и `Manage tax exemptions`. +- Пустые identifiers/exemptions показывают компактный subsection empty state. + +### CustomerAccountSection + +Секция объединяет account decision, notes и terminal lifecycle evidence. Lifecycle status в header остаётся summary badge; здесь находится полный контекст решения. + +![Customer account and notes section](assets/customer-details-redesign/08-account-notes.png) + +```text +┌─ Account & notes ──────────────────────────────────────────────── [⋯] ┐ +│ Account status │ +│ ┌──────────────────┬──────────────────┬──────────────────┐ │ +│ │ ✓ Active │ Disabled │ Blocked │ read-only │ +│ └──────────────────┴──────────────────┴──────────────────┘ │ +│ │ +│ Merchant note │ +│ Prefers delivery after 18:00. │ +│ │ +│ Internal moderation note │ +│ Previous chargeback reviewed and resolved. │ +│ │ +│ [Only when present] Blocked reason / merged target / redacted date │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +- Для `ACTIVE`, `DISABLED`, `BLOCKED` details использует static horizontal status strip без hover/focus/select behavior. +- В edit modal та же геометрия становится настоящим selectable `Segmented block`. +- Для terminal `MERGED` или `REDACTED` вместо трёх selectable-looking segments показывается semantic context panel с target/timestamp. +- `blockedReason` показывается только для `BLOCKED`; пустой `Blocked reason: —` при других statuses отсутствует. +- Merchant note и moderation note разделены labels и helper copy. Обе заметки internal, но принадлежат разным API operations. +- `mergedInto`, `redactedAt` и `deletedAt` показываются только когда присутствуют. +- Section menu содержит `Change customer status`, `Edit merchant note`, `Edit moderation note`, `Merge customer`, `Create privacy request`. + +### Почему нет отдельных Activity & value, Company, Lifecycle и Audit metadata + +```text +CURRENT REDESIGN +──────────────────────────── ─────────────────────────────────── +Header CustomerInfoHeader +Activity & value ├─ lifetime KPI only once + └─ Order activity: breakdown/dates + +Profile & contacts Header: contact summary +Company Customer profile: profile + company + +Notes & moderation Account & notes +Lifecycle └─ decision + notes + terminal events + +Audit metadata View technical metadata modal +``` + +- KPI не дублируются между header и отдельной statistics grid. +- Маленькая Company card не создаёт самостоятельный vertical stop, но company edit остаётся независимым. +- Account decision невозможно корректно читать отдельно от blocked reason и moderation context. +- Технические IDs/revision/source timestamps доступны, но не конкурируют с operational content. + +### View technical metadata + +Read-only utility modal открывается из header overflow: + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Customer technical metadata │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Identity ─────────────────────────────────────────────────────────┐ │ +│ │ Customer ID [gid://shopana/Customer/… copy] │ │ +│ │ IAM principal [principal_… copy] │ │ +│ │ Source Admin import │ │ +│ │ Created by ID [admin_… copy] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Audit ────────────────────────────────────────────────────────────┐ │ +│ │ Revision 14 │ │ +│ │ Created Jul 4, 2024 10:15 │ │ +│ │ Updated Jul 16, 2026 14:32 │ │ +│ │ Activity stats Jul 16, 2026 14:25 │ │ +│ │ Money stats Jul 16, 2026 14:25 │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Modal не имеет `Save`. +- IDs copyable, dates используют общий formatter. +- Consent evidence и workflow raw JSON не добавляются сюда автоматически: это отдельные records, а не Customer metadata. +- Пустой `iamPrincipalId` объясняется как `No linked IAM principal`, а не показывается dash без контекста. + +## Section Edit Modals + +### Общий pattern + +Каждая edit modal использует: + +```text +ModalLayout +├── ModalHeader: close / stable title / primary Save +└── scrollable body, max-width 800 + ├── API Alert, only when present + └── one or more Paper sections +``` + +Общие правила: + +1. `Save` disabled, пока detail loading, форма невалидна, submit выполняется или form не dirty. +2. Любое изменение вызывает `setDirty(true)`; закрытие обрабатывается стандартным Modal Stack confirmation. +3. Field errors находятся под конкретным control; operation/network error — `Alert` над первой `Paper`. +4. После submit ошибка не закрывает modal и не очищает draft. +5. При success: refetch details, success toast, `setDirty(false)`, `forcePop()`. +6. Каждая modal повторно загружает актуального Customer и отправляет только свой subtree с текущим `expectedRevision`. +7. `operationResults.errors` и top-level `userErrors` объединяются hook, затем mapper раскладывает их по section field paths. +8. Формы используют `react-hook-form` и section Zod schemas вместо одного набора локальных `useState` для всех customer sections. +9. Form controls имеют видимые labels; placeholders не заменяют labels. +10. Read-only API values не включаются в submit только потому, что были показаны рядом с form. + +### 1. Edit Personal Profile + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit personal profile [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Name ─────────────────────────────────────────────────────────────┐ │ +│ │ Prefix First name * Middle name │ │ +│ │ [Ms__________] [Maria_______________] [Elena____________] │ │ +│ │ │ │ +│ │ Last name * Suffix │ │ +│ │ [Johnson____________________________] [___________________] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Personal details ────────────────────────────────────────────────┐ │ +│ │ Preferred locale * Date of birth │ │ +│ │ [English (en)________________⌄] [1991-04-12____________] │ │ +│ │ │ │ +│ │ Gender │ │ +│ │ [Female____________________________________________________] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- `shopLocales` используется для locale select. +- Email, phone, company и status отсутствуют. +- Empty optional strings нормализуются в `null` mapper-ом. +- Submit subtree: `{ profile: { prefix, firstName, middleName, lastName, suffix, preferredLocale, dateOfBirth, gender } }`. + +### 2. Edit Contact Details + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit contact details [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Contact details ─────────────────────────────────────────────────┐ │ +│ │ Email │ │ +│ │ [maria@example.com__________________________________________] │ │ +│ │ Current state: ✓ Verified │ │ +│ │ │ │ +│ │ Phone │ │ +│ │ [+12025550147_______________________________________________] │ │ +│ │ Current state: Not verified │ │ +│ │ │ │ +│ │ ℹ Changing a contact value does not mark it as verified. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Email валидируется как email и нормализуется consistently с create flow. +- Phone валидируется/нормализуется в E.164 на display boundary/schema policy. +- `emailVerified` и `phoneVerified` read-only; API не предоставляет update input для этих flags. +- Consent contact points не обновляются молча вместе с customer contact. После save можно показать non-blocking notice, если существующий consent использует старое значение. +- Submit subtree: `{ contact: { email, phoneE164 } }`. + +### 3. Edit Company + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit company [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Company ──────────────────────────────────────────────────────────┐ │ +│ │ Company name │ │ +│ │ [Northstar Studio___________________________________________] │ │ +│ │ │ │ +│ │ Job title │ │ +│ │ [Purchasing manager________________________________________] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Tax identifiers не находятся в этой modal. +- Submit subtree: `{ company: { companyName, jobTitle } }`. + +### 4. Manage Addresses + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Manage addresses [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Addresses ─────────────────────────────────────────── 2 total ───┐ │ +│ │ [Home] 1 Main St, Austin, TX [Shipping] [Valid] [⋯] │ │ +│ │ [Office] 18 Market Rd, Boston, MA [Billing] [Unvalid.] [⋯] │ │ +│ │ │ │ +│ │ [+ Add address] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Defaults: Shipping [Home________⌄] Billing [Office________⌄] │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Parent modal хранит address collection draft и explicit `created`, `updated`, `deleted` state. +- Row menu: `Edit address`, `Set as shipping default`, `Set as billing default`, `Remove`. +- `Remove` меняет только draft. Existing address попадает в `deleteIds` исключительно после explicit remove. +- Нельзя определять deletion как «ID отсутствует в текущей загруженной странице». +- Default selects используют existing или newly created draft identity; mapper преобразует new item default в корректные create flags, а existing — в `defaultShippingAddressId` / `defaultBillingAddressId`. +- Validation status read-only и не сбрасывается обычным edit UI. +- Submit subtree: `{ addresses: { create, update, deleteIds, defaultShippingAddressId, defaultBillingAddressId } }`. + +Nested address item modal: + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit address [Apply] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Address identity ─────────────────────────────────────────────────┐ │ +│ │ Label Company │ │ +│ │ [Home___________] [____________________________________] │ │ +│ │ Name fields: prefix / first / middle / last / suffix │ │ +│ │ Phone [_______________________________________________] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ ┌─ Location ─────────────────────────────────────────────────────────┐ │ +│ │ Address line 1 * / Address line 2 │ │ +│ │ City * / Region / Region code / Postal code │ │ +│ │ Country * │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ [ ] Default shipping [ ] Default billing │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- `Apply` обновляет только parent draft; server mutation выполняется по parent `Save`. +- `shopCountries` используется для country select. +- Latitude/longitude не добавляются как raw text fields в основной editor. +- Закрытие nested modal без `Apply` не меняет parent draft. + +### 5. Edit Marketing Consents + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit marketing consents [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Email ────────────────────────────────────────────────────────────┐ │ +│ │ Current: Subscribed · Confirmed opt-in · Updated Jul 10 │ │ +│ │ State * [Subscribed__________________________________⌄] │ │ +│ │ Opt-in level [Confirmed opt-in____________________________⌄] │ │ +│ │ Contact point [maria@example.com___________________________] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ ┌─ SMS ──────────────────────────────────────────────────────────────┐ │ +│ │ Current: No record │ │ +│ │ [+ Add consent state] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Rows строятся по `CustomerConsentChannel`, но absent channel явно обозначается `No record`, а не `Not subscribed`. +- Admin-selectable states ограничены `CustomerConsentAdminState`. +- Existing `INVALID` и `REDACTED` не преобразуются в `NOT_SUBSCRIBED` при hydration. Любой переход начинается только после explicit user choice; redacted record может быть read-only согласно backend policy. +- Contact point required для изменяемого/new row. +- `set` содержит только dirty channel transitions. Неизменённые rows не должны создавать новые immutable consent events. +- `sourceIp`, `userAgent`, `evidence` и source metadata не редактируются raw JSON controls в этой modal. +- Submit subtree: `{ consents: { set: changedChannels } }`. + +### 6. Edit Customer Groups + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit customer groups [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Manual group memberships ────────────────────────────────────────┐ │ +│ │ Groups * [VIP, Wholesale____________________________⌄] │ │ +│ │ Primary group [VIP_______________________________________⌄] │ │ +│ │ │ │ +│ │ Rule/import/system memberships remain unchanged. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Options загружаются из active customer groups editor context. +- Primary group обязан входить в selected group IDs; удаление primary очищает/переназначает selection явно. +- Input contract заменяет только manual memberships; non-manual memberships не включаются в form values. +- Submit subtree: `{ groups: { memberships: [{ groupId, isPrimary }] } }`. + +### 7. Edit Customer Tags + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit customer tags [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Tags ─────────────────────────────────────────────────────────────┐ │ +│ │ [Newsletter, High intent___________________________________⌄] │ │ +│ │ │ │ +│ │ Selected tags replace the customer's current tag assignments. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Используется searchable multi-select из customer tags editor context. +- Save явно является complete replacement для tag IDs. +- Submit subtree: `{ tags: { tagIds } }`. + +### 8. Edit Manual Segments + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit manual segments [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Manual memberships ──────────────────────────────────────────────┐ │ +│ │ [Austin, Wholesale prospects______________________________⌄] │ │ +│ │ │ │ +│ │ Read-only calculated memberships │ │ +│ │ [High lifetime value · Rule] [Recent buyer · System] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Select предлагает только active `MANUAL` segments. +- Rule/import/system memberships показываются для context, но не входят в editable array. +- Submit subtree: `{ segments: { segmentIds } }`; API заменяет только manual memberships. + +### 9. Change Customer Status + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Change customer status [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Account status ──────────────────────────────────────────────────┐ │ +│ │ [✓ Active ] [ Disabled ] [⊘ Blocked ] │ │ +│ │ │ │ +│ │ ┌────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Active │ │ │ +│ │ │ Customer can participate in normal store flows. │ │ │ +│ │ └────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ Block reason * [visible only for Blocked] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ Current status: Active · Updated Jul 16 │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Selectable values ограничены `CustomerAdminLifecycleStatus`: Active, Disabled, Blocked. +- Merge и redaction не являются status options; это dedicated workflows. +- Consequence copy показывается один раз для выбранного status. +- `BLOCKED` требует non-empty `blockedReason`; для Active/Disabled mapper отправляет `blockedReason: null` согласно API policy. +- Modal недоступна для terminal `MERGED`/`REDACTED` state. +- Submit subtree: `{ status: { status, blockedReason } }`. + +### 10. Edit Merchant Note + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit merchant note [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Merchant note ───────────────────────────────────────────────────┐ │ +│ │ [Prefers delivery after 18:00. ] │ │ +│ │ [ ] │ │ +│ │ 31 / 2000 │ │ +│ │ Visible only to store administrators. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Moderation note отсутствует. +- Submit subtree: `{ note: { note } }`. + +### 11. Edit Moderation Note + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit moderation note [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Internal moderation context ─────────────────────────────────────┐ │ +│ │ [Previous chargeback reviewed and resolved. ] │ │ +│ │ [ ] │ │ +│ │ 43 / 2000 │ │ +│ │ This note is never shown to the customer. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Merchant note и status отсутствуют. +- Submit subtree: `{ moderation: { moderationNote } }`. + +### 12. Manage Tax Identifiers + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Manage tax identifiers [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Tax identifiers ────────────────────────────────────────────────┐ │ +│ │ VAT · US · 98-7654321 [Verified] [Primary] [⋯] │ │ +│ │ GST · CA · 812345678 [Unverified] [⋯] │ │ +│ │ │ │ +│ │ [+ Add identifier] │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Parent draft хранит stable keys, create/update/delete intent. +- Nested item form редактирует type, country, value, status, primary, valid from/to. +- `normalizedValue` и `verifiedAt` read-only и не отправляются обратно. +- В каждый момент не более одного primary identifier; conflict решается в draft до submit. +- Existing identifier попадает в `deleteIds` только после explicit remove. +- Submit subtree: `{ taxIdentifiers: { create, update, deleteIds } }`. + +### 13. Manage Tax Exemptions + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Manage tax exemptions [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Tax exemptions ────────────────────────────────────────────────┐ │ +│ │ RESALE · US / TX · through Dec 31 [Active] [⋯] │ │ +│ │ resale-certificate.pdf │ │ +│ │ │ │ +│ │ [+ Add exemption] │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Nested exemption form: + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit tax exemption [Apply] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ Code * / Country / Region code / Status * │ +│ Valid from / Valid to │ +│ Reason │ +│ Certificate [resale-certificate.pdf________] [Select] [Remove] │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Certificate выбирается существующим Media Picker или upload flow; raw `certificateFileId` не является пользовательским text input. +- `Apply` изменяет parent draft; parent `Save` выполняет server mutation. +- Existing exemption удаляется только explicit remove action. +- Submit subtree: `{ taxExemptions: { create, update, deleteIds } }`. + +## Existing Lifecycle Workflow Modals + +### Merge Customer + +Существующий `CustomerMergeModal` переиспользуется, но при запуске из details получает более строгий context: + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Merge customer [Request merge] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Source customer ─────────────────────────────────────────────────┐ │ +│ │ Maria Johnson · maria@example.com read-only │ │ +│ │ This profile will be merged and closed. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ ┌─ Target customer ─────────────────────────────────────────────────┐ │ +│ │ [Maria J.________________________________________] [Select] │ │ +│ │ The target remains active and receives consolidated data. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ Reason [________________________________________________________] │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Source prefilled текущим Customer и read-only; нельзя случайно запросить merge другого source из открытых details. +- Target использует существующий Customer Picker и исключает source ID. +- Source/target equality валидируется до mutation. +- После request details refetch показывает актуальный lifecycle state. Details не обещает synchronous completion workflow. + +### New Privacy Request + +Существующий `CustomerDataRequestModal` сохраняется: + +- Customer prefilled и read-only при запуске из details. +- Type, legal basis и due date находятся в основной `Request` Paper. +- `requestMetadata` JSON скрывается в collapsed `Advanced` и валидируется как object; пустой/default `{}` не занимает восемь строк основного form. +- Copy объясняет, что privacy erasure и обычный customer delete — разные действия. +- Existing Customer Picker остаётся доступным при запуске modal с lifecycle page, но не нужен в customer-scoped details flow. + +## Destructive Confirmation + +### Delete Customer + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Delete customer? │ +│ │ +│ Maria Johnson will be soft-deleted and removed from active │ +│ customer views. This does not erase personal data. Use a │ +│ privacy request when data erasure is required. │ +│ │ +│ [Cancel] [Delete] │ +└──────────────────────────────────────────────────────────────┘ +``` + +- `Delete` — danger button. +- Mutation отправляет current `expectedRevision`. +- При revision conflict confirmation закрывается, details остаётся открытой и показывает reload action. +- После success details modal закрывается и Customers list refetch выполняется. +- Copy не обещает hard delete: backend `CustomerDeleteScript` выполняет soft delete. + +## Component Reuse Matrix + +| UI responsibility | Переиспользовать | Решение | +|---|---|---| +| Modal shell | `ModalLayout`, `ModalHeader` | Без нового shell/footer | +| Section surface | `Paper`, `PaperHeader` | Один pattern для всех sections | +| Local section action | `EditAction` | `⋯` с понятными menu labels | +| Status/meta header | composition Product/Category `InfoHeader` | Новый `CustomerInfoHeader`, та же структура | +| Avatar | Ant Design `Avatar` | Initials, без fake image | +| IDs | `CopyableChip` | Customer и raw Order IDs | +| Header metrics | `KPITile` | Только реальные lifetime values | +| Dates | `formatDetailDate` + date-only wrapper | Один formatter вместо scattered `toLocaleString()` | +| Currency source | `useDefaultCurrency` | Никогда не брать currency из monetary row | +| Minor money formatting | один shared customer money helper | Убрать два локальных `Intl.NumberFormat` implementations | +| Locale/country | `shopLocales`, `shopCountries` | Human label + stable code | +| Empty content | `EntityDetailsEmptyState` | Customer-specific copy/icon | +| Customer selection | existing Customer Picker | Merge target; не создавать второй picker | +| Certificate selection | existing Media Picker/upload primitives | Не вводить raw file ID field | +| Forms | `react-hook-form`, section Zod schemas | Dirty/errors/submit единообразны | +| Unsaved close | Modal Stack built-in confirmation | Не создавать локальный confirm | +| Merge/privacy workflows | existing lifecycle modals/hooks | Customer details только задаёт scoped payload | + +## Что должно стать отдельными Customer Components + +```text +admin/src/domains/customers/all-customers/components/customer-details-card/ +├── customer-details-card.tsx +├── customer-details-card.styles.ts +├── customer-info-header.tsx +├── sections/ +│ ├── customer-profile-section.tsx +│ ├── customer-order-activity-section.tsx +│ ├── customer-addresses-section.tsx +│ ├── customer-consents-section.tsx +│ ├── customer-classification-section.tsx +│ ├── customer-tax-section.tsx +│ └── customer-account-section.tsx +└── hooks/ + └── use-customer-modals.ts +``` + +Текущий single-file `CustomerDetailsCard` не должен продолжать владеть formatters, confirmation, section composition и всеми modal callbacks одновременно. + +Edit modals рекомендуется разделить физически: + +```text +admin/src/domains/customers/all-customers/modals/ +├── customer-modal/ +├── edit-customer-profile-modal/ +├── edit-customer-contact-modal/ +├── edit-customer-company-modal/ +├── manage-customer-addresses-modal/ +├── edit-customer-address-modal/ +├── edit-customer-consents-modal/ +├── edit-customer-groups-modal/ +├── edit-customer-tags-modal/ +├── edit-customer-segments-modal/ +├── edit-customer-status-modal/ +├── edit-customer-note-modal/ +├── edit-customer-moderation-modal/ +├── manage-customer-tax-identifiers-modal/ +├── edit-customer-tax-identifier-modal/ +├── manage-customer-tax-exemptions-modal/ +├── edit-customer-tax-exemption-modal/ +└── customer-technical-metadata-modal/ +``` + +Address/tax item modals работают с parent draft payload и не вызывают `customerUpdate` самостоятельно. + +## Section/API Ownership + +| Section modal | Единственный update subtree | +|---|---| +| Edit personal profile | `{ profile: { ... } }` | +| Edit contact details | `{ contact: { ... } }` | +| Edit company | `{ company: { ... } }` | +| Manage addresses | `{ addresses: { create, update, deleteIds, defaults } }` | +| Edit marketing consents | `{ consents: { set: changedChannels } }` | +| Edit customer groups | `{ groups: { memberships } }` | +| Edit customer tags | `{ tags: { tagIds } }` | +| Edit manual segments | `{ segments: { segmentIds } }` | +| Change customer status | `{ status: { status, blockedReason } }` | +| Edit merchant note | `{ note: { note } }` | +| Edit moderation note | `{ moderation: { moderationNote } }` | +| Manage tax identifiers | `{ taxIdentifiers: { create, update, deleteIds } }` | +| Manage tax exemptions | `{ taxExemptions: { create, update, deleteIds } }` | +| Merge customer | отдельные customer merge create/update mutations | +| New privacy request | отдельные data request create/update mutations | + +Это ключевое правило redesign: открытие `Edit personal profile` не должно гидратировать или повторно отправлять contacts, addresses, consents, groups, tags, segments, status, notes или tax collections из устаревшего snapshot. + +## Data Readiness + +Большая часть wireframe уже поддерживается `CustomerDetailsFields`, но read path должен быть скорректирован: + +- `Customer($id)` получает `$currencyCode` из `useDefaultCurrency()`; +- `monetaryStatistics` запрашивается как `first: 1, where: { currencyCode: { _eq: $currencyCode } }`, а не `first: 50`; +- UI не выбирает `edges[0]` как currency fallback; +- `statistics.updatedAt` и selected monetary statistics `updatedAt` достаточно для technical metadata; +- `accountStatus`, `emailVerified`, `phoneVerified` read-only: update contract для них отсутствует; +- avatar/image отсутствует: используются initials, не fake remote image; +- order list и federated Orders Admin entity отсутствуют: показываются только statistics и copyable raw first/last Order IDs; +- trend/time-series отсутствуют: `PeriodSwitch` не используется; +- active privacy request count и current merge workflow не вложены в Customer query: details не показывает фиктивные counters; +- `createdByUserId` не является Admin user object: не форматировать его как имя; +- consent details используют только latest event из existing newest-first events connection; full audit history потребует отдельного paginated flow; +- tax certificate выбирается через existing Media Picker; schema extension не требуется. + +Collection limits требуют отдельной защиты: + +- details fragment сейчас ограничивает addresses 50, groups 100, segments/tags 250, tax collections 50; +- если `totalCount > edges.length`, details показывает `Showing N of M` и не притворяется complete; +- complete-replacement editors groups/tags/segments не могут сохранять truncated snapshot; +- перед Save такой editor обязан загрузить полный editable manual/tag set либо использовать отдельный paginated selection flow, который хранит selected IDs независимо от текущей page; +- addresses/tax editors строят deletion только из explicit user actions, никогда из отсутствия item в загруженной page; +- default address management должно учитывать defaults, находящиеся за пределами первой page. + +Edit modal получает `entityId`, повторно загружает актуальную entity и использует её `revision` для optimistic concurrency. + +При conflict: + +```text +This customer changed after the editor was opened. +[Reload latest data] +``` + +Автоматически повторять update с новой revision нельзя. Reload заменяет form draft только после explicit confirmation, если form dirty. + +## Loading, Empty и Error States + +### Details Loading + +- Skeleton повторяет примерную геометрию header + первые sections, а не один paragraph на 12 rows. +- Старые `previousData` могут оставаться видимыми при background refetch с небольшим loading indicator; нельзя заменять весь modal skeleton при каждом child save. + +### Not Found + +```text +Customer not found +It may have been deleted or is no longer available in this store. +[Close] +``` + +### Section Empty States + +- Profile company: `No company information`. +- Activity: `No customer activity statistics yet`. +- Addresses: `No addresses added`. +- Consents: `No marketing consent records`. +- Classification: subsection-level `No groups`, `No tags`, `No segments`. +- Tax: subsection-level `No tax identifiers`, `No tax exemptions`. +- Notes: `No merchant note` / `No moderation note` as secondary text. + +Empty state не скрывает section action, если пользователь может добавить данные. + +### Errors + +- Initial details query error без data — один `Alert` + retry action. +- Background refetch error сохраняет текущий content и показывает non-destructive alert. +- Mutation transport error и `userErrors` не закрывают child modal. +- Collection operation error привязывается к соответствующему address/tax/consent row, если API возвращает field path. +- Delete conflict оставляет details открытой и предлагает reload. + +## Responsive Behavior + +- При ширине `< 720px` header meta и actions переносятся, avatar/title остаются на одной логической линии. +- KPI grid становится `2 × 2`; при ещё меньшей ширине — одна колонка. +- Profile rows и Order activity two-column breakdown складываются вертикально. +- Address cards из двух колонок переходят в одну. +- Contact line переносится между email и phone; значение не обрезается без copy access. +- Status strip сохраняет три равных segments, пока labels помещаются; на очень узкой ширине labels остаются text + icon и допускают vertical stack. +- Nested item forms используют одну колонку; `grid-template-columns` не создаёт горизонтальный scroll. +- Tables для addresses/tax не вводятся: compact rows/cards лучше переживают узкий viewport. + +## Accessibility и Keyboard Behavior + +- Все icon-only header actions имеют `aria-label` и Tooltip. +- Verification не зависит только от green icon: accessible text сообщает verified state. +- Details status strip не имеет `role=tab`, `button` или selectable keyboard behavior. +- Edit status segmented control доступен стрелками и объявляет selected value. +- Dropdown menu items получают `data-testid` на item object по принятому Ant Design pattern. +- Copyable email, phone и IDs доступны с клавиатуры; success state объявляется без переноса focus. +- Address/tax row `⋯` имеет accessible label с item identity, например `Actions for Home address`. +- Nested modal возвращает focus на row/action, из которой была открыта. +- Error summary ведёт к первому invalid field; field errors связаны через `aria-describedby`. +- Semantic status всегда имеет text label, не только цвет. + +## Acceptance Criteria + +- Customer Details modal имеет стабильный title `Customer details`; display name находится в `CustomerInfoHeader`. +- Header повторяет Product/Category composition и показывает только реальные customer values. +- Header использует Orders, Net spent, Average order и Returns без fake trends/periods. +- Monetary values используют default currency проекта; fallback на первую monetary statistics row отсутствует. +- Activity KPI не дублируются отдельной тяжёлой `Statistic` grid. +- Profile, order activity, addresses, consents, classification, tax и account context имеют domain-specific presentation вместо одинаковых `Descriptions` blocks. +- Company включена в profile reading section, но имеет отдельный edit flow. +- Audit metadata отсутствует в основном scroll и доступна через read-only utility modal. +- Lifecycle status, blocked reason и internal notes находятся в одной `Account & notes` section. +- Terminal `MERGED`/`REDACTED` не выглядят как admin-selectable statuses. +- Profile, contact, company, addresses, consents, groups, tags, segments, status, note, moderation, tax identifiers и tax exemptions имеют независимые edit flows. +- Каждая edit modal отправляет только собственный `CustomerUpdateInput` subtree и current revision. +- Consent hydration не преобразует `INVALID`/`REDACTED` в `NOT_SUBSCRIBED` и не создаёт events для unchanged channels. +- Rule/import/system segment memberships остаются read-only; manual editor не удаляет их. +- Complete-replacement collection editors не сохраняют truncated snapshots. +- Address/tax deletion возникает только из explicit remove action. +- Address и tax item nested modals изменяют parent draft по `Apply`, а server mutation выполняется parent `Save`. +- Tax certificate выбирается через media picker/upload, raw file ID не вводится вручную. +- Merge использует текущего Customer как read-only source и existing Customer Picker для target. +- Privacy request и soft delete имеют разный consequence copy. +- Delete confirmation точно сообщает soft-delete semantics и отправляет `expectedRevision`. +- Loading, not found, empty, refetch error и revision conflict states описаны и не уничтожают form draft. +- Узкий viewport не создаёт горизонтальный scroll основной формы. +- Все actions доступны с клавиатуры и имеют текстовые accessible names. diff --git a/admin/docs/discount-details-card-implementation-plan.md b/admin/docs/discount-details-card-implementation-plan.md new file mode 100644 index 000000000..066311b78 --- /dev/null +++ b/admin/docs/discount-details-card-implementation-plan.md @@ -0,0 +1,488 @@ +# Discount Details Card — план имплементации + +## 1. Цель + +Реализовать в Admin детальную карточку скидки по Pencil-дизайну из +`pen/domains/discounts.pen`, открыть её в `ModalStack` из таблицы скидок и +питать данными GraphQL Pricing API. Исключение первой версии — KPI summary и +usage history: это намеренно статические значения из макетов. + +План не предполагает API-output view model. Компоненты принимают `ApiDiscount` +или типы, напрямую выведенные из результата GraphQL-операции. Статические KPI +не маскируются под API-данные и хранятся отдельно от GraphQL-контракта. + +## 2. Источники дизайна + +Основные экраны: + +| Сценарий | Pencil node | +| --- | --- | +| Amount off products / code / active | `d0AwSm` | +| Buy X Get Y / automatic / scheduled | `Ol41e` | +| Amount off order / code / paused | `Vztrx` | +| Free shipping / automatic / active | `hFvJ3` | +| Draft только с title / пустые секции | `yEt8h` | + +Переиспользуемые секции: + +| Секция | Pencil node | +| --- | --- | +| Summary | `HnqZv` | +| Value & usage | `grfdm` | +| Applies to | `h8PACg` | +| Customer eligibility | `H99ci` | +| Availability & limits | `h90YJS` | +| Tags | `RaeX3` | +| Discount codes | `fQnng` | +| External references | `I3ae9s` | +| Channels | `avwDn` | +| Combinations | `mvjvy` | + +Эталонная ширина контента карточки — `800px`, вертикальный gap между +секциями — `12px`, фон body модалки — нейтральный серый. В коде размеры и +цвета должны выражаться через Ant Design tokens и `antd-style`, а не через +копирование hex-значений из Pencil. + +Подтверждённое продуктовое отклонение от текущего Pencil: на automatic discount +секция `Discount codes` не показывается. Empty-state codes, присутствующий на +экране `Ol41e`, не переносится в реализацию. + +## 3. Текущее состояние + +- Список скидок уже получает `ApiDiscount` через `DISCOUNTS_QUERY`. +- Таблица пока не открывает details modal по клику строки. +- Зарегистрирована только модалка создания `discount-create`. +- Отдельных `DISCOUNT_DETAILS_QUERY`, `useDiscount`, `DiscountModal` и + `DiscountDetailsCard` нет. +- Pricing API уже имеет `pricingQuery.discount(id: ID!)` и большую часть + необходимых полей агрегата. +- В `ProductDetailsCard` уже есть `PricingBlock` с нужным визуальным паттерном: + header, основная value-колонка, history chart и ряд из четырёх KPI. +- UI-kit уже предоставляет `Paper`, `PaperHeader`, `ModalLayout` и + `ModalStack`; их необходимо переиспользовать. + +## 4. Границы первой поставки + +Первая поставка — read-only карточка с API-backed данными скидки и статическим +демо-набором KPI: + +- загрузка скидки по ID; +- loading, error и not-found состояния; +- все применимые секции и их empty/stale варианты; +- корректные различия четырёх `DiscountKind`, двух `DiscountMethod` и + lifecycle/effective statuses; +- hardcoded KPI и usage chart, соответствующие пяти Pencil-сценариям; +- открытие модалки из списка; +- action-кнопки выводятся только если передан соответствующий callback. + +Редакторы секций, lifecycle-команды, activity modal и CRUD внешних ссылок не +входят в первую поставку карточки. Контракты `onEditSection` и +`onViewActivity` нужно заложить сразу, чтобы добавление edit-модалок не меняло +структуру presentational components. + +## 5. Целевая структура файлов + +```text +admin/src/domains/inventory/discounts/ +├── components/ +│ └── discount-details-card/ +│ ├── discount-details-card.tsx +│ ├── discount-details-card.styles.ts +│ ├── discount-kpi.fixtures.ts +│ ├── formatters.ts +│ ├── types.ts +│ ├── index.ts +│ └── sections/ +│ ├── discount-summary-section.tsx +│ ├── discount-value-usage-adapter.tsx +│ ├── applies-to-section.tsx +│ ├── customer-eligibility-section.tsx +│ ├── channels-section.tsx +│ ├── combinations-section.tsx +│ ├── availability-limits-section.tsx +│ ├── tags-section.tsx +│ ├── discount-codes-section.tsx +│ ├── external-references-section.tsx +│ └── index.ts +├── graphql/ +│ ├── fragments.ts +│ ├── queries.ts +│ └── operation-types.ts +├── hooks/ +│ ├── use-discount.ts +│ └── index.ts +├── modals/ +│ └── discount-modal/ +│ ├── discount-modal.tsx +│ └── index.ts +└── modals.ts + +admin/src/domains/inventory/components/ +└── details-metrics-widget/ + ├── details-metrics-widget.tsx + ├── details-metrics-widget.styles.ts + ├── types.ts + └── index.ts +``` + +Мелкие повторяющиеся элементы (`SectionAction`, KPI tile, status badge, +reference status) можно вынести в локальную `components/` подпапку только +после появления второго реального использования. Не создавать абстракции +заранее. + +## 6. GraphQL read path + +### 6.1 Fragment + +Добавить `DISCOUNT_DETAILS_FRAGMENT` с именем GraphQL-фрагмента +`DiscountDetailsFields`. Он должен получать: + +- identity: `id`, `revision`, `title`, `method`, `kind`, `discountClass`, + `currency`, `state`, `effectiveStatus`, `createdAt`, `updatedAt`, + `archivedAt`; +- definition: `priority`, purchase modes, schedule, usage limit и + `appliesOncePerCustomer`; +- `rule` с inline fragments для `DiscountAmountOffRule`, + `DiscountBuyXGetYRule` и `DiscountFreeShippingRule`; +- `minimumRequirement`; +- `usage` и верхнеуровневые usage counters; +- `targetSelections`, включая role/type, reference status и union target с + минимальными полями Product/Variant/Category для превью; +- `buyerContext`, customers/segments и reference status; +- `channels`, `combinations`, `tags`; +- `codes(first: 20)` с `totalCount`, status и usage counters; +- `externalReferences(first: 20)` с `totalCount`, sync state, URL, error и + timestamps; + +`redemptions` не добавлять только ради KPI первой версии: KPI статические и не +должны неявно смешивать hardcode с частичными API-вычислениями. + +Не расширять `DISCOUNT_LIST_FRAGMENT`: тяжёлые связи должны загружаться только +при открытии details modal. + +### 6.2 Query и hook + +Добавить `DISCOUNT_DETAILS_QUERY`: + +```graphql +query DiscountDetails($id: ID!) { + pricingQuery { + discount(id: $id) { + ...DiscountDetailsFields + } + } +} +``` + +В `operation-types.ts` определить response/variables через generated API +types, не реэкспортируя их. `useDiscount(id)` владеет Apollo `useQuery`, +пропускает запрос при `null`, возвращает: + +```ts +{ + discount, + loading, + error, + refetch, +} +``` + +Никаких output mappers: форматирование дат, денег, enum labels и display +fallbacks выполняется на границе отображения. + +## 7. Контракт карточки + +```ts +type DiscountDetailsSection = + | "summary" + | "value-usage" + | "applies-to" + | "customer-eligibility" + | "channels" + | "combinations" + | "availability-limits" + | "tags" + | "codes" + | "external-references"; + +interface DiscountDetailsCardProps { + discount: DiscountDetailsQueryDiscount; + onEditSection?: (section: DiscountDetailsSection) => void; + onViewActivity?: () => void; + onRefresh?: () => Promise; +} +``` + +`DiscountDetailsQueryDiscount` должен быть выведен из operation result либо +оставаться `ApiDiscount`, если fragment действительно формирует полный тип. +Не вводить `DiscountDetailsViewModel`. + +Root component использует `Flex vertical gap={12}` и отвечает за порядок и +условное включение секций. В частности, `DiscountCodesSection` добавляется в +дерево только для `discount.method === DiscountMethod.Code`. Остальные +бизнес-ветвления конкретной секции остаются внутри неё. + +## 8. Маппинг секций на API + +### 8.1 Summary + +- Status badge: `effectiveStatus`, а не только persisted `state`. +- Title fallback: `title ?? primaryCode ?? "Untitled discount"`. +- Identity chips: short ID, method, kind, currency. +- Updated metadata: `updatedAt`. +- KPI первой версии — hardcode из Pencil, включая Orders, Uses, Discounted + sales, Average discount, trends, выбранный период и compare state. +- Значения вынести в `discount-kpi.fixtures.ts` и выбирать по устойчивому + presentation key: `kind + method + effectiveStatus`, с отдельным draft + fallback. Не размещать числа inline внутри JSX. +- Статические KPI не использовать для бизнес-логики, permissions, lifecycle + actions или расчёта других секций. +- В коде рядом с fixture оставить явный TODO на замену analytics query, чтобы + hardcode не воспринимался как фактическая статистика скидки. + +Статические значения из текущих экранов: + +| Pencil-сценарий | Orders | Uses | Discounted sales | Avg. discount | +| --- | ---: | ---: | ---: | ---: | +| Amount off products / code / active | 156 | 248 | $18,420 | 20% | +| Buy X Get Y / automatic / scheduled | 0 | 0 | $0 | — | +| Amount off order / code / paused | 82 | 94 | $24,800 | $25 | +| Free shipping / automatic / active | 314 | 314 | $42,100 | $12.40 | +| Draft / title only | 0 | 0 | $0 | — | + +Тренды и точки графика также копируются из соответствующего Pencil-сценария, +а не рассчитываются из `ApiDiscount`. + +### 8.2 Value & usage + +`Value & usage` — не новая самостоятельная UI-секция. Это переиспользование +presentational части pricing widget из `ProductDetailsCard`. + +Текущий `PricingBlock` нельзя импортировать напрямую в discounts: он связан с +`ApiProduct`, `useProductPricingWidget`, variant selection, price mutations и +product edit modals. Перед добавлением скидок из него нужно извлечь общий +presentational shell в `inventory/components/details-metrics-widget`: + +- `Paper` container и header slot; +- responsive two-column layout; +- primary value/details slot; +- history/chart slot; +- общий ряд `KPITile`; +- loading и empty presentation; +- существующие spacing, breakpoints и token-based styles. + +После extraction текущий `PricingBlock` остаётся product-specific container: +он продолжает загружать product pricing, обрабатывать variant selection и edit +actions, но рендерит извлечённый `DetailsMetricsWidget`. Его публичный контракт +и поведение не меняются. + +Discount adapter только преобразует `ApiDiscount` и локальный hardcoded usage +fixture в presentation props общего widget. Он не копирует разметку и styles +`PricingBlock`. + +Discount primary value выбирается по `rule.__typename` и `kind`: + +- amount off: percentage/fixed amount, allocation, minimum requirement, + maximum discount; +- Buy X Get Y: qualifier, benefit, quantities, value, per-order limit; +- free shipping: minimum requirement и maximum shipping price; +- incomplete draft: единый empty state без попытки угадать rule. + +Usage tiles берутся из `usage`: limit, consumed, committed/reserved, reversed, +remaining. Все BigInt GraphQL scalars форматируются без преобразования в +небезопасный JS `number`. + +Usage history первой версии также берётся из статического fixture выбранного +Pencil-сценария. Для draft/scheduled сценария используется предусмотренный +макетом empty state. График не вычисляется из `usage` counters. + +### 8.3 Applies to + +- Группировать `targetSelections` по role: qualifier/benefit. +- Поддержать `ALL_PRODUCTS`, `PRODUCTS`, `VARIANTS`, `CATEGORIES`. +- Для resolved targets показывать title/thumbnail/secondary identity. +- Для отсутствующего `target` показывать stale row по `targetId`, + `targetType` и `referenceStatus`, не удаляя ссылку из UI. +- Для больших наборов показывать первые карточки и count; action «Open all» + включать только при наличии callback/modal. +- Для order/free-shipping scope отображать семантическое описание из kind, + если catalog target по контракту не требуется. + +### 8.4 Customer eligibility + +- `ALL`: «All customers». +- `CUSTOMERS`: список customer references, resolved customer и stale state. +- `SEGMENTS`: список segment IDs и reference state. +- Warning выводится при любом stale/missing reference. + +Текущий `ApiDiscountEligibleSegment` не содержит display name сегмента. До +API/Federation расширения показывать сокращённый `segmentId`; не подставлять +фиктивные названия из дизайна. + +### 8.5 Channels + +- Отрисовать все `channels`, featured state и summary count. +- Label получать из существующего channel registry, если он доступен; иначе + форматировать `code` без локального hardcoded списка. +- Empty state обязателен для draft. + +### 8.6 Combinations + +- Три фиксированные категории определяются enum `DiscountClass`: + Product, Order, Shipping. +- Active state — наличие класса в `combinations` либо соответствующем + `combinesWith*` field. +- Не смешивать Channels и Combinations в одну Paper: в актуальном дизайне это + самостоятельные секции. + +### 8.7 Availability & limits + +- Timeline: `startsAt`, `endsAt`, `effectiveStatus` и текущая дата. +- Purchase modes: one-time/subscription. +- Usage policy: aggregate limit, once per customer, per-order limit из + Buy X Get Y rule. +- Automatic discounts явно показывают недоступность code-specific limits. +- Все даты форматировать единым `Intl.DateTimeFormat` с locale Admin и + timezone браузера; ISO strings не преобразовывать в модели. + +### 8.8 Tags + +- Источник — `discount.tags: string[]`, а не product `ApiTag[]`. +- В первой поставке секция read-only, с empty state. +- Не переиспользовать напрямую product `TagsSection`: её mutation contract + (`productId`, revision, `ProductTagOperationAction`) несовместим со скидками. +- Визуально повторить `Paper`/tag presentation; mutation-backed версия позже + использует полную замену `discountUpdate.operations.tags`. + +### 8.9 Discount codes + +- Секция существует только для `discount.method === DiscountMethod.Code`. +- Для `CODE`: rows с code, status, usage/remaining и updated/disabled metadata. +- Если code discount пока не содержит кодов, внутри секции показывается empty + state с действием добавления кода, когда передан callback. +- Для `AUTOMATIC` не рендерить ни `Paper`, ни заголовок, ни empty state, ни + зарезервированный вертикальный gap. +- Учитывать `codes.totalCount`; первая карточка показывает максимум 20 записей + и оставляет extension point для отдельного списка. + +### 8.10 External references + +- Показать system/type/id, direction, sync status, last synced/error и external + link. +- Stale/error reference получает warning styling из Ant tokens. +- Empty state и total count обязательны. +- Add/open actions рендерятся только при наличии callback. + +## 9. Форматирование и визуальные правила + +- Ant Design `6.x`, React `19`, `antd-style`; не добавлять новую UI dependency. +- Базовая оболочка каждой секции — `Paper`; заголовки — `PaperHeader` либо + локальное расширение, сохраняющее тот же spacing/border pattern. +- Использовать Ant tokens: `colorBgContainer`, `colorFillQuaternary`, + `colorBorderSecondary`, `colorText`, `colorTextSecondary`, status colors, + `borderRadiusLG`, spacing tokens. +- Иконки — существующий `react-icons/lu`, соответствующие Lucide names из + Pencil. +- BigInt-like значения обрабатывать как string/BigInt. Не применять `Number()` + к counters и minor amounts без проверки safe range. +- Money formatter принимает minor amount и `CurrencyCode`; логику держать в + discount-local formatter до появления общего Admin money utility. +- Empty state должен сохранять высоту и структуру Paper, а не удалять секцию: + draft-экран `yEt8h` показывает все секции. +- На узком viewport KPI и двухколоночные блоки переходят в одну колонку без + горизонтального overflow. + +## 10. Details modal и открытие из списка + +1. Добавить modal type `discount` и payload с `entityId` в `modals.ts`. +2. Экспортировать `useDiscountModal = createModalStackHook("discount")`. +3. Зарегистрировать lazy `DiscountModal` в `admin/src/domains/modals.tsx`. +4. `DiscountModal` извлекает string ID, вызывает `useDiscount` и отображает: + skeleton, error alert, not-found Empty либо `DiscountDetailsCard`. +5. Использовать `ModalLayout` с title `Discount details`, back/close поведением + текущего ModalStack и без submit button. +6. В discounts page открыть modal через `onRowClicked`, передавая только ID. + Не передавать list fragment как источник details, чтобы не показывать + частично устаревший агрегат. +7. После будущих section mutations вызывать `refetch` внутри modal, а + обновление list query оставить hook/cache слою. + +## 11. API gaps для полной точности Pencil + +Для pixel/behavior parity дизайна понадобятся отдельные backend изменения: + +| Пробел | Что требуется | +| --- | --- | +| Замена hardcoded KPI | агрегаты orders, uses, discounted sales, average discount и previous-period deltas | +| Замена hardcoded usage chart | time-series buckets по discount ID и period | +| Segment label | resolved segment reference или federated display field | +| Полноценный activity action | paginated redemption/activity query и отдельная modal/page | + +Эти данные нельзя восстанавливать на клиенте загрузкой всей истории. До +расширения API карточка показывает явно изолированные статические значения из +Pencil; они не считаются фактической аналитикой. + +## 12. Последовательность реализации + +1. Извлечь presentational shell существующего product pricing widget в общий + `DetailsMetricsWidget` и перевести `PricingBlock` на его использование без + изменения product behavior. +2. Добавить details fragment/query/operation types и `useDiscount`. +3. Реализовать formatters, status/kind/method label helpers и изолированные KPI + fixtures для пяти Pencil-сценариев. +4. Реализовать `DiscountSummarySection` и проверить все status/title fallbacks. +5. Реализовать discount adapter для общего `DetailsMetricsWidget` для каждого + rule typename и draft. +6. Реализовать Applies to и Customer eligibility, включая stale references. +7. Реализовать Channels, Combinations и Availability & limits. +8. Реализовать Tags, условную Codes и External references с empty states. +9. Собрать `DiscountDetailsCard` в порядке Pencil-экрана. +10. Добавить `DiscountModal`, registry entry и открытие по клику строки. +11. Сверить пять Pencil-сценариев и responsive layout, включая regression + сверку product `PricingBlock` после extraction. +12. Отдельным изменением расширить Pricing API для analytics/segment gaps. +13. После появления edit-модалок подключить callbacks секций без изменения их + read contracts. + +## 13. Проверка результата + +Автоматическая проверка проекта: + +- не запускать `test` и `tsc`; +- выполнить Admin build через `shopana-cli` MCP, как требует проект; +- GraphQL codegen запускать через `shopana-cli` только если операция участвует + в настроенном operation codegen; иначе сохранить принятый проектом ручной + `operation-types.ts` pattern. + +Ручной smoke checklist: + +- строка таблицы открывает правильный discount ID; +- refresh страницы внутри открытой модалки не ломает загрузку; +- корректно отображаются 4 kinds и code/automatic methods; +- draft с незаполненным rule показывает все empty sections; +- active/scheduled/paused/expired/archived имеют корректные status styles; +- stale target/customer/segment не приводит к crash; +- null `endsAt`, unlimited usage и very large BigInt отображаются корректно; +- automatic discount полностью исключает секцию Discount codes из DOM; +- code discount без кодов показывает empty state внутри секции; +- external URL безопасно открывается с `noopener,noreferrer`; +- keyboard close и focus behavior остаются ответственностью ModalStack; +- на узкой ширине нет horizontal overflow; +- в console нет React key, hydration и GraphQL warnings. + +## 14. Критерии готовности первой поставки + +- Нет импортов из общих runtime mocks и API-output mapper для Discount; + hardcoded KPI находятся только в локальном `discount-kpi.fixtures.ts`. +- Детальная query не утяжеляет list query. +- Все применимые Pencil-секции присутствуют в одинаковом порядке и имеют empty + state; Discount codes отсутствует у automatic discount. +- Rule-specific данные не смешиваются между discount kinds. +- `Value & usage` и product `PricingBlock` используют один общий + presentational widget; дублированной pricing-widget разметки и styles нет. +- Extraction общего widget не меняет загрузку, редактирование и внешний вид + product pricing. +- Stale references видимы и объяснимы пользователю. +- Hardcoded KPI визуально соответствуют Pencil и не участвуют в бизнес-логике. +- Компонент готов к подключению section edit modals через callbacks. +- Admin build завершается успешно. diff --git a/admin/docs/discovery-settings-search-section-wireframes.md b/admin/docs/discovery-settings-search-section-wireframes.md new file mode 100644 index 000000000..f414b8844 --- /dev/null +++ b/admin/docs/discovery-settings-search-section-wireframes.md @@ -0,0 +1,522 @@ +# Discovery Settings: Search UI wireframes и API integration + +## Цель + +Спроектировать секцию `Search` для страницы Discovery Settings и описать её +интеграцию с существующим GraphQL API. + +На странице также будет секция настроек фильтров, но её UI, поведение и API +integration не входят в этот документ. В wireframe для неё зарезервировано +только место. + +Search-блок должен визуально соответствовать секциям существующих Admin UI +модалок: `Paper`, `PaperHeader`, стандартные Ant Design controls, `Alert` и +`Skeleton`. + +Документ опирается на: + +- `admin/src/domains/discovery/search/settings/page/page.tsx`; +- `admin/src/ui-kit/paper`; +- `admin/src/layouts/data`; +- `admin/src/domains/discovery/search/graphql`; +- `services/listing/src/api/graphql-admin/schema/search.graphql`; +- `knowledge/vault/patterns/admin-graphql-layer.md`. + +## Scope + +В scope: + +- searchable fields и их relevance weights; +- typo tolerance; +- out-of-stock policy; +- чтение, инициализация и обновление `SearchSettings`; +- loading, validation, API errors и version conflict; +- accessibility behavior секции Search. + +Не в scope: + +- проектирование секции Filters; +- GraphQL-интеграция настроек фильтров; +- Product Boosts и Synonyms; +- Search Explain preview; +- изменение API-контракта или route страницы. + +## Route и page composition + +Текущая страница зарегистрирована по адресу: + +```text +/:orgName/:storeName/search/settings +``` + +Путь сохраняется: на него уже ведут Product Boost и Synonym Group модалки. + +```mermaid +flowchart TD + A["Discovery settings"] --> B["Search — этот документ"] + A --> C["Filters — отдельный дизайн"] + B --> B1["Searchable fields and weights"] + B --> B2["Typo tolerance"] + B --> B3["Out-of-stock policy"] +``` + +`DataLayout` владеет page header и основным scroll-контейнером. Search +рендерится отдельным `Paper`. Секция Filters располагается ниже, но её состав +здесь не определяется. + +## Desktop wireframe + +```text +┌────────────────────────────────────────────────────────────────────────────┐ +│ Discovery settings [Save changes] │ +│ Configure storefront discovery. │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [Search API / version conflict alert — only when present] │ +│ │ +│ ┌─ Search ────────────────────────────────────────────────────────────────┐ │ +│ │ Choose what shoppers can search and how results are ranked. │ │ +│ │ │ │ +│ │ Searchable fields Weight │ │ +│ │ [●] Product title [ 8.0 ] │ │ +│ │ Main product name shown in the storefront. │ │ +│ │ [●] Variant title [ 5.0 ] │ │ +│ │ Variant-specific title and identifying text. │ │ +│ │ [●] Vendor name [ 2.0 ] │ │ +│ │ Brand or supplier name. │ │ +│ │ [●] Category name [ 1.0 ] │ │ +│ │ Category assigned to the product. │ │ +│ │ │ │ +│ │ ──────────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ Typo tolerance [○ Off] │ │ +│ │ Retry eligible searches with typo expansion when exact search is weak. │ │ +│ │ │ │ +│ │ Out-of-stock products │ │ +│ │ (●) Show in relevance order │ │ +│ │ ( ) Show after available products │ │ +│ │ ( ) Hide from search results │ │ +│ │ │ │ +│ │ Updated 14 Jul 2026, 14:42 │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Filters ───────────────────────────────────────────────────────────────┐ │ +│ │ Separate design. │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +Filters placeholder показывает только положение второй секции. Текст +`Separate design` не является предложенным production empty state. + +Контент страницы ограничен `max-width: 1000px`, как в существующем +`SettingsLayout`. Для Search используется стандартный `Paper` без повторного +создания border, radius и shadow в page styles. + +## Header и save action + +- Title: `Discovery settings`. +- Subtitle: `Configure storefront discovery.` +- Primary action: `Save changes`. +- Это page-level action: она сохраняет все editable секции страницы. Пока + editable является только Search, submit содержит только Search. Editable + Filters нельзя добавлять без включения её dirty state, validation и submit в + тот же page-level orchestration. +- При неинициализированных settings label единственной primary action меняется + на `Initialize search`. Внутри Search Paper в этом состоянии второй submit + button не показывается. +- `Save changes` disabled, пока Search form не dirty, выполняется query/mutation + либо есть client-side ошибки. +- `Initialize search` доступна после загрузки валидного initial draft. +- Во время mutation кнопка показывает loading и запрещает повторный submit. +- Успех показывает toast `Search settings saved.` и сбрасывает dirty state. + +При появлении editable Filters label остаётся `Save changes`, а disabled/loading +state вычисляется из всех editable секций. Частичное сохранение одной секции +глобальной кнопкой запрещено. + +Если form dirty, переход на другой route, browser back/forward или закрытие +страницы требует подтверждения потери изменений. Guard снимается после +успешного submit или явного discard. Во время mutation повторная навигация и +submit блокируются. + +## Search Paper + +### Searchable fields + +Каждая строка соответствует одному значению `SearchField`: + +| API value | Label | Help text | Default weight | +|---|---|---|---:| +| `PRODUCT_TITLE` | Product title | Main product name shown in the storefront. | 8 | +| `VARIANT_TITLE` | Variant title | Variant-specific title and identifying text. | 5 | +| `VENDOR_NAME` | Vendor name | Brand or supplier name. | 2 | +| `CATEGORY_NAME` | Category name | Category assigned to the product. | 1 | + +Строка содержит: + +- `Switch`, включающий поле в поиск; +- `InputNumber` для relevance weight; +- короткое описание источника searchable text. + +Правила: + +- включённые поля отправляются в `SearchSettingsValuesInput.fields`; +- weight должен быть конечным числом `> 0` и `<= 100`; +- weight disabled, когда поле выключено; +- draft сохраняет последнее значение weight при выключении поля; +- минимум одно поле должно оставаться включённым; +- последнее поле разрешено выключить, после чего группа показывает + `At least one search field must be enabled.`, Save становится disabled, а + пользователь может включить любое поле обратно; +- group error получает `role="alert"`, а все field switches связаны с ним через + `aria-describedby`; +- порядок строк фиксирован registry-порядком API; +- drag-and-drop не используется; +- weight является относительным коэффициентом, поэтому UI не нормализует сумму + и не показывает проценты. + +`InputNumber` использует `min={0.01}`, `max={100}`, `step={0.1}` и precision до +двух знаков после десятичного разделителя. Decimal separator следует текущей +Ant Design locale. Значение из paste или ручного ввода дополнительно проверяется +schema как конечное число в диапазоне `(0, 100]`. + +Form draft хранит все известные поля, включая выключенные: + +```ts +interface SearchSettingsFormValues { + fields: Array<{ + field: SearchField; + enabled: boolean; + weight: number; + }>; + typoToleranceEnabled: boolean; + outOfStockPolicy: SearchOutOfStockPolicy; +} +``` + +Mapper исключает disabled fields из GraphQL input. + +### Typo tolerance + +Один `Switch` мапится напрямую в `typoToleranceEnabled`. + +Label: `Typo tolerance`. + +Help text: + +```text +Retry eligible searches with typo expansion when exact search is weak. +``` + +UI не обещает исправление каждого запроса и не воспроизводит server-side typo +algorithm. + +### Out-of-stock policy + +Используется вертикальный `Radio.Group`, поскольку варианты +взаимоисключающие: + +| UI label | API value | Поведение | +|---|---|---| +| Show in relevance order | `SHOW` | Availability не меняет search ordering. | +| Show after available products | `PLACE_LAST` | Недоступные товары остаются в выдаче после доступных. | +| Hide from search results | `HIDE` | Недоступные товары исключаются из search membership. | + +UI не вычисляет availability самостоятельно и не связывает policy напрямую с +quantity. Источник истины — canonical listing availability. + +### Metadata + +Внизу Search Paper вторичным текстом показывается +`Updated `. Version является внутренним optimistic +concurrency token и в пользовательском UI не отображается. + +## Loading и initialization states + +### Initial loading + +- Header отображается сразу. +- Search Paper показывает `Skeleton` с геометрией будущих field rows. +- Save disabled. +- При background refetch сохраняется `previousData`; fullscreen spinner не + нужен. + +### Settings не инициализированы + +`listingQuery.search.settings === null` является отдельным состоянием, а не +transport error. + +Search Paper показывает явный initialize flow: + +```text +Search is not configured +Set the initial searchable fields, typo tolerance, and inventory policy. + +[prefilled editable Search form] +``` + +Submit выполняется единственной page header action `Initialize search`. + +Initial draft: + +```json +{ + "fields": [ + { "field": "PRODUCT_TITLE", "enabled": true, "weight": 8 }, + { "field": "VARIANT_TITLE", "enabled": true, "weight": 5 }, + { "field": "VENDOR_NAME", "enabled": true, "weight": 2 }, + { "field": "CATEGORY_NAME", "enabled": true, "weight": 1 } + ], + "typoToleranceEnabled": false, + "outOfStockPolicy": "SHOW" +} +``` + +Значения повторяют текущие `SearchFieldRegistry` и store initialization +handler. Они показаны пользователю до submit и могут быть изменены. + +Initialize вызывает существующую `settingsUpdate` mutation с +`expectedVersion: 0`. После успеха API возвращает settings с `version: 1`. + +### Query error + +При query error Search Paper показывает retryable `Alert` и `Retry`. Client не +подставляет initial defaults, потому что error нельзя трактовать как +`settings === null`. + +## Validation и error states + +### Client validation + +Клиент проверяет только быстрые детерминированные правила: + +- минимум одно enabled field; +- отсутствие duplicate field; +- weight `> 0` и `<= 100` для каждого enabled field; +- наличие `outOfStockPolicy`. + +### API error mapping + +| API field suffix | UI target | +|---|---| +| `input.fields` | Searchable fields group | +| `input.fields..field` | соответствующая field row | +| `input.fields..weight` | weight input отправленной field row | +| `input.typoToleranceEnabled` | Typo tolerance row | +| `input.outOfStockPolicy` | Out-of-stock group | +| `expectedVersion` | version conflict alert | +| unknown / empty | Search Paper alert | + +Поскольку mapper удаляет disabled fields, API index относится к отправленному +массиву. Mapper submit должен сохранить lookup `submittedIndex → SearchField`, +чтобы `fields.1.weight` отображалась на правильной строке полного draft. + +Network и unexpected errors показываются в Search Paper `Alert` с +`role="alert"`. Draft не сбрасывается. + +### Version conflict + +При `VERSION_CONFLICT` или field suffix `expectedVersion`: + +```text +These search settings changed after the page was opened. +[Reload latest settings] +``` + +Автоматический retry с новой version запрещён. Если form dirty, reload требует +подтверждение перед заменой draft. Save disabled до успешной загрузки актуальных +settings. После reload кнопка снова включается только после нового изменения. + +## GraphQL read integration + +Текущий `SearchSettingsEditorFields` содержит только `version` и `updatedAt`. +Для Search form fragment расширяется editable fields: + +```graphql +fragment SearchSettingsEditorFields on SearchSettings { + version + fields { + field + weight + } + typoToleranceEnabled + outOfStockPolicy + updatedAt +} +``` + +Query: + +```graphql +query SearchSettingsEditor { + listingQuery { + search { + settings { + ...SearchSettingsEditorFields + } + } + } +} +``` + +Компоненты получают `ApiSearchSettings` и другие generated API types напрямую +из `@/graphql/types`. Отдельная API-output view model не создаётся. Form draft +остаётся UI-local моделью. + +## GraphQL write integration + +Используется существующая mutation: + +```graphql +mutation SearchSettingsUpdate( + $expectedVersion: Int! + $operations: SearchSettingsOperationsInput! +) { + listingMutation { + search { + settingsUpdate( + expectedVersion: $expectedVersion + operations: $operations + ) { + settings { + ...SearchSettingsEditorFields + } + operationResults { + ...SearchSettingsOperationResultFields + } + userErrors { + code + field + message + } + } + } + } +} +``` + +Update variables: + +```json +{ + "expectedVersion": 3, + "operations": { + "settings": { + "fields": [ + { "field": "PRODUCT_TITLE", "weight": 8 }, + { "field": "VARIANT_TITLE", "weight": 5 }, + { "field": "VENDOR_NAME", "weight": 2 } + ], + "typoToleranceEnabled": true, + "outOfStockPolicy": "PLACE_LAST" + } + } +} +``` + +Submit flow: + +1. Выполнить client validation. +2. Построить полный replacement `operations.settings`; mutation не является + patch. +3. Для update взять `expectedVersion` из последнего загруженного + `SearchSettings.version`; для initialization использовать `0`. +4. Найти в `operationResults` результат с `type === SETTINGS_UPDATE`. Считать + submit успешным только если payload и этот result существуют, `userErrors` + пуст, `result.applied === true`, а `result.errors` пусты. Пустой массив или + отсутствие matching result не являются успехом. +5. Заменить form baseline данными `payload.settings`. +6. Сбросить dirty state и показать success toast. +7. Не закрывать страницу и не выполнять navigation. + +Mutation возвращает расширенный `SearchSettingsEditorFields`, чтобы получить +canonical fields, новую version и `updatedAt` без обязательного refetch. + +## Frontend ownership + +```text +admin/src/domains/discovery/search/ + graphql/ + index.ts # compatibility re-exports only + settings/ + graphql/ + fragments.ts + queries.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-search-editor-context.ts + use-update-search-settings.ts + index.ts + mappers/ + search-settings-form.mapper.ts + search-errors.mapper.ts + index.ts + page/ + page.tsx + search-settings-paper.tsx + schema.ts + types.ts +``` + +- `page.tsx` владеет page composition, Search dirty state, notification и + submit orchestration. +- `use-search-editor-context.ts` владеет Apollo read state и возвращает + `ApiSearchSettings | null`. +- `use-update-search-settings.ts` владеет mutation и объединяет top-level + `userErrors` с errors matching `SETTINGS_UPDATE` operation result. +- `search-settings-form.mapper.ts` преобразует API settings в form draft и + form draft в `ApiSearchSettingsOperationsInput`. +- `search-errors.mapper.ts` расширяется Search Settings targets, сохраняя + существующие mappings Product Boost и Synonym Group. +- Структура и ownership секции Filters здесь не определяются. + +## Accessibility + +- Каждый `Switch`, `InputNumber` и radio имеет видимый label. +- Field switch получает accessible name `Search product title` и связан с help + text/error через `aria-describedby`. +- Weight label включает field name: `Product title weight`. +- Ошибки submit получают `role="alert"`; focus переходит к первому ошибочному + Search control. +- Enabled/disabled state не передаётся только цветом. +- После успешного сохранения focus остаётся на Save button. + +## Test IDs + +| Element | `data-testid` | +|---|---| +| Page | `discovery-settings-page` | +| Save | `discovery-settings-save-button` | +| Search Paper | `search-settings-section` | +| Field switch | `search-field--switch` | +| Weight input | `search-field--weight-input` | +| Typo tolerance | `search-typo-tolerance-switch` | +| Out-of-stock group | `search-out-of-stock-policy` | + +API value нормализуется один раз: `PRODUCT_TITLE → product-title`. + +Test IDs секции Filters будут определены вместе с её отдельным дизайном. + +## Acceptance criteria + +- Search оформлен стандартными `Paper`/`PaperHeader` и визуально совпадает с + modal sections. +- Filters обозначен только как соседняя секция без спроектированных controls, + поведения или API integration. +- Search form показывает четыре `SearchField`, enabled state и weight из API. +- Минимум одно поле включено; при выключении всех полей показывается group + error и Save блокируется; enabled weights валидны в диапазоне `(0, 100]`. +- Typo tolerance и out-of-stock policy мапятся напрямую в GraphQL input. +- `settings === null` запускает initialization flow с `expectedVersion: 0`. +- Query error не маскируется под неинициализированные settings. +- Update отправляет full replacement и использует актуальный + `SearchSettings.version`. +- Version conflict не перезаписывает чужие изменения автоматически. +- API errors с путями `input.fields...` отображаются у соответствующих controls. +- После успешной mutation form baseline строится из возвращённых canonical + settings, dirty state сбрасывается, страница остаётся открытой. +- Dirty form защищена от случайной потери при navigation или закрытии страницы. diff --git a/admin/docs/facet-create-value-candidates-grid-plan.md b/admin/docs/facet-create-value-candidates-grid-plan.md new file mode 100644 index 000000000..59c44a6b5 --- /dev/null +++ b/admin/docs/facet-create-value-candidates-grid-plan.md @@ -0,0 +1,589 @@ +# План AG Grid с facet value candidates в CreateFacetModal + +## Цель + +Добавить в `CreateFacetModal` секцию `Values`, похожую на Shopify screenshot: + +- список `facetValueCandidates` грузится в AG Grid после выбора facet source; +- список поддерживает поиск и сортировку по имени; +- строки выбираются чекбоксами; +- выбранные candidates создаются как `FacetValue` вместе с facet в той же `facetCreate` мутации; +- фронт не вызывает отдельные `facetValueCreate` после создания facet. + +## Текущий контекст + +Backend уже имеет read API: + +- `services/catalog/src/api/graphql-admin/schema/facet.graphql`; +- `CatalogQuery.facetValueCandidates(...)`; +- `FacetValueCandidate { id, facetType, sourceHandle, handle, label }`; +- `FacetValueCandidatesMetaInput { candidateType, sourceHandles, facetId }`; +- поддержанные candidate types: `TAG`, `OPTION`, `FEATURE`; +- `PRICE` и `IN_STOCK` не имеют raw value candidates. + +Create flow сейчас неполный для этой задачи: + +- `CreateFacetModal` хранит только `label`, `slug`, `facetType`, `uiType`; +- `FacetSourceSelector` пока выбирает только тип через dropdown; +- backend `FacetCreateScript` требует ровно один source в `sources`; +- `FacetCreateInput` пока не принимает выбранные value candidates; +- `FacetValueCreateInput` создаёт values отдельно, но это не подходит, потому нужно создание в одной мутации. + +Важно: этот план предполагает, что план `admin/docs/facet-source-picker-modal-plan.md` уже реализован или реализуется первым. Без выбранного `source.handle` нельзя корректно запросить `facetValueCandidates`. + +## UX flow + +1. Пользователь открывает `Create facet`. +2. В поле `Label` выбирает source через source-control. +3. После выбора source форма получает: + - `facetType`; + - `source.handle`; + - `source.name`. +4. Если `facetType` равен `TAG`, `OPTION` или `FEATURE`, ниже показывается секция `Values`. +5. Секция грузит candidates через `facetValueCandidates(meta: { candidateType, sourceHandles: [source.handle] })`. +6. Пользователь ищет values через search input. +7. Пользователь сортирует колонку `Value` по имени. +8. Пользователь отмечает чекбоксами candidates, которые нужно создать вместе с facet. +9. Submit вызывает одну мутацию `facetCreate`. +10. Backend в одной транзакции создаёт: + - facet; + - facet source; + - source facet values по выбранным candidates. + +Для `PRICE` и `IN_STOCK` секция `Values` скрыта: у них нет candidate list из catalog data. + +## Текстовый wireframe + +### Create facet modal + +```text +┌────────────────────────────────────────────────────────────────────┐ +│ Create facet [x] │ +├────────────────────────────────────────────────────────────────────┤ +│ General │ +│ │ +│ Label │ +│ ┌──────────────┬─────────────────────────────────────────────────┐ │ +│ │ Color ▾ │ Color │ │ +│ └──────────────┴─────────────────────────────────────────────────┘ │ +│ │ +│ UI type │ +│ [Checkbox] [Radio] [Dropdown] │ +│ │ +│ Values 12 selected │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 🔍 Search filter values │ │ +│ ├────────────────────────────────────────────────────────────────┤ │ +│ ├─────┬──────────────────────────────────────────────┬───────────┤ │ +│ │ [ ] │ Value │ Source │ │ +│ ├─────┼──────────────────────────────────────────────┼───────────┤ │ +│ │ [x] │ Blue │ color │ │ +│ │ [x] │ Green │ color │ │ +│ │ [ ] │ Red │ color │ │ +│ │ [ ] │ Yellow │ color │ │ +│ └─────┴──────────────────────────────────────────────┴───────────┘ │ +│ 1-20 of 86 [Prev] [Next] │ +├────────────────────────────────────────────────────────────────────┤ +│ [Cancel] [Create]│ +└────────────────────────────────────────────────────────────────────┘ +``` + +### Empty state до выбора source + +```text +┌────────────────────────────────────────────────────────────────┐ +│ Values │ +│ Select a source to load available values. │ +└────────────────────────────────────────────────────────────────┘ +``` + +### Empty search + +```text +┌────────────────────────────────────────────────────────────────┐ +│ 🔍 Search filter values: "cotton blue" │ +├────────────────────────────────────────────────────────────────┤ +│ No values found │ +└────────────────────────────────────────────────────────────────┘ +``` + +## UI model + +Расширить форму: + +```ts +interface FacetSourceFormValue { + handle: string; + name: string; +} + +interface FacetValueCandidateFormValue { + id: string; + handle: string; + label: string; + sourceHandle: string; +} +``` + +Zod schema: + +```ts +source: z.object({ + handle: z.string().trim().min(1), + name: z.string().trim().min(1), +}).nullable(), +selectedValueCandidates: z.array(z.object({ + id: z.string().trim().min(1), + handle: z.string().trim().min(1), + label: z.string().trim().min(1), + sourceHandle: z.string().trim().min(1), +})).default([]), +``` + +Поведение: + +- при смене source очищать `selectedValueCandidates`; +- при смене source перезапрашивать candidates; +- сохранять выбранные candidates между страницами AG Grid в `Map`; +- header checkbox выбирает только текущую загруженную страницу; +- submit отправляет только накопленную selection, а не только видимые rows. + +## AG Grid секция + +Добавить компонент: + +```text +admin/src/domains/inventory/facets/modals/create-facet-modal/facet-value-candidates-grid.tsx +``` + +Props: + +```ts +interface FacetValueCandidatesGridProps { + facetType: FacetType; + sourceHandle: string | null; + value: FacetValueCandidateFormValue[]; + onChange: (value: FacetValueCandidateFormValue[]) => void; +} +``` + +Колонки: + +- selection column: AG Grid checkbox selection; +- `Value`: `label`, sortable; +- `Source`: `sourceHandle`, вторичная колонка для диагностики, можно скрыть если выбран только один source; +- опционально `Handle`: `handle`, скрытая/debug колонка. + +AG Grid config: + +```ts +rowSelection={{ + mode: "multiRow", + checkboxes: true, + headerCheckbox: true, + enableClickSelection: true, + enableSelectionWithoutKeys: true, +}} +getRowId={(params) => params.data.id} +defaultColDef={{ + sortable: true, + resizable: false, +}} +``` + +Сортировка: + +- сортировка работает стандартно через клик по AG Grid header cell, как на страницах; +- сортируемая колонка: `Value`; +- mapping `label -> label`; +- default sort: `label asc`, затем `id asc`; +- при смене sort сбрасывать cursor pagination на первую страницу. + +Поиск: + +- search input в секции `Values`, placeholder `Search filter values`; +- debounce 250-300 ms; +- search condition: + +```ts +{ + _or: [ + { label: { _containsi: search } }, + { handle: { _containsi: search } }, + ], +} +``` + +Pagination: + +- использовать cursor pagination как в `EntityPickerContent`; +- page size по умолчанию `20`; +- selection хранить отдельно от текущей страницы. + +## GraphQL query для candidates + +Добавить в `admin/src/domains/inventory/facets/graphql/queries.ts`: + +```graphql +query FacetValueCandidates( + $first: Int + $after: String + $last: Int + $before: String + $where: FacetValueCandidateWhereInput + $orderBy: [FacetValueCandidateOrderByInput!] + $meta: FacetValueCandidatesMetaInput! +) { + catalogQuery { + facetValueCandidates( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + meta: $meta + ) { + edges { + cursor + node { + id + facetType + sourceHandle + handle + label + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +} +``` + +Variables для create modal: + +```ts +{ + first: 20, + where: buildFacetValueCandidateSearchCondition(search), + orderBy: [{ field: "label", direction: "asc" }], + meta: { + candidateType: facetType, + sourceHandles: [source.handle], + }, +} +``` + +`candidateType` передавать только для `TAG`, `OPTION`, `FEATURE`. Для остальных типов query не выполнять. + +## Hook слой + +Добавить: + +```text +admin/src/domains/inventory/facets/hooks/use-facet-value-candidates.ts +``` + +Hook должен возвращать: + +```ts +{ + candidates: FacetValueCandidateGridRow[]; + loading: boolean; + error?: ApolloError; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string | null; + endCursor?: string | null; + }; + totalCount: number; + refetch: () => Promise; +} +``` + +Если в admin уже есть общий cursor/page helper для GraphQL connection, использовать его. Если нет, сделать локальный hook без выноса новой shared abstraction. + +## API contract для одной мутации + +Расширить GraphQL schema: + +```graphql +input FacetCreateValueCandidateInput { + handle: String! + label: String! + sourceHandle: String! +} + +input FacetCreateInput { + facetType: FacetType! + slug: String! + uiType: FacetUIType + selectionMode: FacetSelectionMode + label: String! + sources: [FacetCreateSourceInput!] + valueCandidates: [FacetCreateValueCandidateInput!] +} +``` + +Почему не `sourceValueIds`: + +- candidates не являются `FacetValue` до создания facet; +- в create flow у них есть stable candidate `handle`, но нет `FacetValue.id`; +- backend должен валидировать candidate handles через `facetValueCandidates` repository logic перед insert. + +Mutation example: + +```graphql +mutation FacetCreate($input: FacetCreateInput!) { + catalogMutation { + facetCreate(input: $input) { + facet { + id + label + facetType + values { + id + kind + handle + label + } + } + userErrors { + field + message + code + } + } + } +} +``` + +Variables: + +```json +{ + "input": { + "facetType": "OPTION", + "slug": "color", + "label": "Color", + "uiType": "CHECKBOX", + "selectionMode": "MULTI", + "sources": [ + { "handle": "color", "name": "Color" } + ], + "valueCandidates": [ + { "sourceHandle": "color", "handle": "color:blue", "label": "Blue" }, + { "sourceHandle": "color", "handle": "color:green", "label": "Green" } + ] + } +} +``` + +## Backend реализация + +Расширить DTO: + +```text +services/catalog/src/scripts/facet/dto/index.ts +``` + +Добавить: + +```ts +export interface FacetCreateValueCandidateInput { + handle: string; + label: string; + sourceHandle: string; +} +``` + +`FacetCreateParams`: + +```ts +valueCandidates?: FacetCreateValueCandidateInput[]; +``` + +В `MutationResolver.facetCreate` передавать `args.input.valueCandidates`. + +В `FacetCreateScript`: + +1. Проверить source как сейчас. +2. Если `valueCandidates` пустой или отсутствует, создать только facet. +3. Если `facetType` не `TAG | OPTION | FEATURE`, вернуть user error: + - field: `["valueCandidates"]`; + - code: `INVALID`; + - message: `Facet value candidates are not supported for this facet type`. +4. Нормализовать candidates: + - trim `handle`; + - trim `label`; + - trim `sourceHandle`; + - удалить дубли по `handle`; + - сохранить порядок selection. +5. Проверить, что каждый `sourceHandle` входит в выбранные `sources`. +6. Валидировать, что каждый candidate всё ещё доступен: + - вызвать repository helper, который получает candidates по `candidateType`, `sourceHandles` и списку `handle`; + - сравнить requested handles с найденными; + - если есть отсутствующие, вернуть user error `SOURCE_VALUE_NOT_AVAILABLE`. +7. Создать facet. +8. Создать source facet values в той же транзакции: + - `kind = "source"`; + - `handle = candidate.handle`; + - `label = candidate.label`; + - `sortIndex` по порядку selection; + - `enabled = true`. + +Repository helper: + +```text +services/catalog/src/repositories/facet/FacetRepository.ts +``` + +Добавить метод: + +```ts +findFacetValueCandidatesByHandles(args: { + candidateType: FacetValueCandidateType; + sourceHandles: string[]; + handles: string[]; +}): Promise +``` + +Он должен переиспользовать те же concrete views, что и `getFacetValueCandidates`, и фильтровать: + +- `storeId`; +- `locale`; +- `facetType`; +- `sourceHandle in sourceHandles`; +- `handle in handles`. + +Создание values лучше делать repository методом, а не прямыми insert в script: + +```ts +createSourceFacetValues(args: { + facetId: string; + values: Array<{ + handle: string; + label: string; + sortIndex: number; + enabled: boolean; + }>; +}): Promise +``` + +## Frontend mapper + +Расширить `FacetFormInput`: + +```ts +source: { + handle: string; + name: string; +} | null; +selectedValueCandidates: Array<{ + handle: string; + label: string; + sourceHandle: string; +}>; +``` + +`mapFacetFormToCreateInput()`: + +```ts +return { + label: values.label.trim(), + slug: normalizeFacetSlug(values.slug), + facetType: values.facetType, + uiType: values.uiType, + selectionMode: getDefaultFacetSelectionMode(values.uiType), + sources: values.source + ? [{ handle: values.source.handle, name: values.source.name }] + : [], + valueCandidates: values.selectedValueCandidates.map((candidate) => ({ + handle: candidate.handle, + label: candidate.label, + sourceHandle: candidate.sourceHandle, + })), +}; +``` + +## Ошибки + +Create modal: + +- `sources` errors показывать на source-control; +- `valueCandidates` errors показывать над grid; +- если candidate стал недоступен между загрузкой и submit, оставить modal открытой и показать backend error; +- если source изменился, selection очищается без подтверждения, потому values относятся к старому source. + +AG Grid: + +- loading: стандартный AG Grid loading overlay; +- empty before source: отдельный текстовый state; +- empty search: `No values found`; +- API error: `Alert` внутри секции `Values`, кнопка retry. + +## Файлы для реализации + +Backend: + +- `services/catalog/src/api/graphql-admin/schema/facet.graphql`; +- `services/catalog/src/resolvers/admin/MutationResolver.ts`; +- `services/catalog/src/scripts/facet/dto/index.ts`; +- `services/catalog/src/scripts/facet/FacetCreateScript.ts`; +- `services/catalog/src/repositories/facet/FacetRepository.ts`; +- generated GraphQL resolver types после codegen. + +Admin: + +- `admin/src/domains/inventory/facets/graphql/queries.ts`; +- `admin/src/domains/inventory/facets/graphql/mutations.ts`; +- `admin/src/domains/inventory/facets/graphql/operation-types.ts`; +- `admin/src/domains/inventory/facets/hooks/use-facet-value-candidates.ts`; +- `admin/src/domains/inventory/facets/modals/create-facet-modal/schema.ts`; +- `admin/src/domains/inventory/facets/modals/create-facet-modal/create-facet-modal.tsx`; +- `admin/src/domains/inventory/facets/modals/create-facet-modal/facet-value-candidates-grid.tsx`; +- `admin/src/domains/inventory/facets/mappers/facet-input.mapper.ts`; +- generated admin GraphQL types after schema/codegen. + +## Implementation order + +1. Реализовать/доделать source picker, чтобы create form имела `source`. +2. Расширить backend `FacetCreateInput` и `FacetCreateScript`. +3. Добавить repository helper для валидации selected candidates. +4. Обновить generated schema/types. +5. Добавить admin query/hook для `facetValueCandidates`. +6. Добавить `FacetValueCandidatesGrid` в create modal. +7. Расширить form schema и mapper. +8. Проверить create flow вручную. + +## Проверка + +По правилам проекта не запускать `test` и `tsc`. + +Минимальная проверка: + +1. Запустить project codegen/schema generation через `shopana-cli`/npm flow, если менялась GraphQL schema. +2. Запустить `build`, когда нужна новая версия кода. +3. Вручную проверить: + - `Create facet`; + - выбрать `OPTION` source `color`; + - увидеть список values; + - поиск по `blue`; + - сортировку `Value`; + - выбор нескольких чекбоксов; + - submit одной `facetCreate`; + - созданный facet содержит выбранные source values. + +## Открытые решения + +1. Нужно ли автоматически preselect all candidates по умолчанию или selection должна стартовать пустой. +2. Нужна ли отдельная колонка `Grouped values` как на screenshot, если grouping/merge будет реализован позже. +3. Нужно ли разрешить batch select across all filtered results, или header checkbox должен выбирать только текущую страницу. +4. Должны ли созданные source values сразу иметь paired group values, или grouping остаётся отдельным follow-up flow. diff --git a/admin/docs/facet-source-picker-modal-plan.md b/admin/docs/facet-source-picker-modal-plan.md new file mode 100644 index 000000000..d14629af1 --- /dev/null +++ b/admin/docs/facet-source-picker-modal-plan.md @@ -0,0 +1,380 @@ +# План модалки выбора facet source + +## Цель + +Сделать выбор source в `CreateFacetModal` не через dropdown с типом facet, а через отдельную модалку-пикер в стиле screenshot: + +- пользователь нажимает на source-control в поле label; +- открывается modal entity picker `Select source`; +- список грузится из `facetSourceCandidates`; +- список фильтруется по нужному `FacetType`; +- пользователь выбирает один source; +- create form сохраняет `facetType` и конкретный `source`; +- `facetCreate` получает `sources: [{ handle, name }]`. + +Паттерн реализации: `usePageQuery + modal entity picker`. В текущем admin уже есть общий `EntityPickerContent` с `pageConfig`/cursor pagination. Для этого flow нужно добавить source-specific data hook, который использует тот же контракт picker-а и внутри вызывает `usePageQuery` для GraphQL connection. + +## Текущий контекст + +Backend уже имеет API для source candidates: + +- schema: `services/catalog/src/api/graphql-admin/schema/facet.graphql`; +- query: `catalogQuery.facetSourceCandidates`; +- type: `FacetSourceCandidate { id, locale, facetType, handle, name }`; +- resolver/repository фильтруют по текущему store/project и locale; +- default backend sorting уже отдаёт fixed sources, tags, options, features в стабильном порядке. + +Admin create modal сейчас: + +- файл: `admin/src/domains/inventory/facets/modals/create-facet-modal/create-facet-modal.tsx`; +- `FacetSourceSelector` выбирает только `FacetType`; +- `mapFacetFormToCreateInput()` не передаёт `sources`; +- schema формы не хранит выбранный source handle/name. + +## UX flow + +1. Пользователь открывает `Create facet`. +2. В поле label слева видит source pill/button: + - если source не выбран: `Source`; + - если выбран: краткий label source, например `Color`, `Brand`, `Price`. +3. По клику открывается `FacetSourcePickerModal`. +4. Модалка показывает search, таблицу и cursor pagination. +5. Для `PRICE` и `IN_STOCK` можно выбрать fixed source; для `TAG`, `OPTION`, `FEATURE` выбираются реальные candidates из API. +6. После подтверждения picker возвращает один `FacetSourcePickerEntity`. +7. Форма обновляет: + - `facetType = entity.facetType`; + - `source = { handle: entity.handle, name: entity.name ?? fallback }`; + - `uiType` пересчитывается через `getDefaultFacetUiType(facetType)`, если текущий `uiType` не разрешён. +8. Submit отправляет `facetCreate(input.sources)`. + +## Текстовый wireframe + +### Create facet modal + +```text +┌────────────────────────────────────────────────────────────┐ +│ Create facet [x] │ +├────────────────────────────────────────────────────────────┤ +│ General │ +│ │ +│ Label │ +│ ┌──────────────┬─────────────────────────────────────────┐ │ +│ │ Color ▾ │ Color │ │ +│ └──────────────┴─────────────────────────────────────────┘ │ +│ │ +│ UI type │ +│ [Checkbox] [Radio] [Dropdown] │ +│ │ +├────────────────────────────────────────────────────────────┤ +│ [Cancel] [Create]│ +└────────────────────────────────────────────────────────────┘ +``` + +### Select source modal + +```text +┌────────────────────────────────────────────────────────────┐ +│ Select source [x] │ +├────────────────────────────────────────────────────────────┤ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ 🔍 Search filter sources │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ Name Type │ +│ ────────────────────────────────────────────────────────── │ +│ Price Standard │ +│ Brand Standard │ +│ Category Standard │ +│ Color Product option │ +│ Denominations Product option │ +│ Material Product feature │ +│ Tags Tag │ +│ In stock Standard │ +│ │ +├────────────────────────────────────────────────────────────┤ +│ 1-20 of 47 [Prev] [Next] │ +│ [Cancel] [Select]│ +└────────────────────────────────────────────────────────────┘ +``` + +## UI model + +Добавить entity type `facet-source`. + +```ts +interface FacetSourcePickerEntity extends IPickableEntity { + id: string; + title: string; + facetType: FacetType; + handle: string; + name: string; + typeLabel: string; +} +``` + +Mapping: + +- `id`: `${facetType}:${handle}` или API `id`; +- `title`: `name ?? handle`; +- `facetType`: API `facetType`; +- `handle`: API `handle`; +- `name`: `name ?? handle`; +- `typeLabel`: + - `PRICE`, `IN_STOCK`, `TAG` -> `Standard`; + - `OPTION` -> `Product option`; + - `FEATURE` -> `Product feature`. + +Колонки picker-а: + +- `Name`: основной текст `title`, вторичная строка `Source: ${handle}` только если полезно отличать display name от handle; +- `Type`: `typeLabel`. + +Selection mode: только `single`. + +## GraphQL интеграция + +Добавить query в `admin/src/domains/inventory/facets/graphql/queries.ts`: + +```graphql +query FacetSourceCandidates( + $first: Int + $after: String + $last: Int + $before: String + $where: FacetSourceCandidateWhereInput + $orderBy: [FacetSourceCandidateOrderByInput!] +) { + catalogQuery { + facetSourceCandidates( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + id + facetType + handle + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +} +``` + +Search condition: + +```ts +function buildFacetSourceSearchCondition(search: string): ApiFacetSourceCandidateWhereInput { + return { + _or: [ + { name: { _containsi: search } }, + { handle: { _containsi: search } }, + ], + }; +} +``` + +Type filter: + +```ts +const facetTypeFilter = allowedFacetTypes.length + ? { facetType: { _in: allowedFacetTypes } } + : undefined; +``` + +Итоговый `where` собирается через `_and`: + +- `pageQuery.where` из search/filter widget; +- `facetTypeFilter`, если picker открыт для конкретного набора типов; +- `excludeIds`, если нужно скрыть уже выбранный source. + +## Hook слой через usePageQuery + +Добавить hook рядом с facets hooks: + +```text +admin/src/domains/inventory/facets/hooks/use-facet-source-candidates.ts +``` + +Ответ hook-а должен быть connection-shaped и совместимым с `IEntityPickerDataResult`: + +```ts +useFacetSourceCandidatesPageQuery({ + first, + after, + last, + before, + where, + orderBy, +}) +``` + +Если в проекте уже есть shared `usePageQuery`, использовать его как единственный владелец: + +- cursor variables; +- `pageInfo`; +- `totalCount`; +- loading/error; +- сохранение предыдущих данных при `cache-and-network`. + +Если shared `usePageQuery` отсутствует в admin, добавить тонкий local wrapper для этого query, но не дублировать pagination state внутри modal. State остаётся у `EntityPickerContent`/page config. + +## Entity picker config + +Добавить конфиг: + +```text +admin/src/domains/inventory/facets/pickers/facet-source-picker-config.ts +``` + +Config: + +- `entityType: "facet-source"`; +- `entityName: "Source"`; +- `entityNamePlural: "Sources"`; +- `searchEnabled: true`; +- `filterSchema`: минимум filter по `facetType`, если нужен ручной фильтр; +- `pageConfig.storageKey: "facet-source-picker-grid-state"`; +- `pageConfig.sortFieldMapping`: `name -> name`, `facetType -> facetType`, `handle -> handle`; +- `pageConfig.buildSearchCondition`: `buildFacetSourceSearchCondition`; +- `useData`: вызывает `useFacetSourceCandidatesPageQuery`; +- `getRowId`: `entity.id`. + +Зарегистрировать config через `registerEntityPickerConfig(facetSourcePickerConfig)`. + +## Modal API + +Можно использовать generic `entity-picker`: + +```ts +const { openPicker } = useEntityPicker({ + entityType: "facet-source", + selectionMode: "single", + initialSelection: selectedSource ? [selectedSource.id] : [], + queryMeta: { + allowedFacetTypes: [FacetType.Price, FacetType.Tag, FacetType.Option, FacetType.Feature, FacetType.InStock], + }, + onConfirm: ([source]) => { + if (!source) return; + setValue("facetType", source.facetType, { shouldValidate: true }); + setValue("source", { + handle: source.handle, + name: source.name, + }, { shouldValidate: true, shouldDirty: true }); + }, +}); +``` + +Если нужен title ровно `Select source`, добавить dedicated wrapper modal `FacetSourcePickerModal`, который переиспользует `EntityPickerContent`, но задаёт свой title и confirm text. Это ближе к screenshot и не ломает generic `Select Sources`. + +## Изменения формы CreateFacetModal + +Расширить schema: + +```ts +source: z.object({ + handle: z.string().trim().min(1), + name: z.string().trim().min(1), +}).nullable() +``` + +Validation: + +- `source` обязателен для всех `FacetType`, потому backend `FacetCreateScript` сейчас ожидает ровно один source; +- при смене source обновлять `facetType`; +- при несовместимом `uiType` выбирать дефолтный `uiType`. + +Default values: + +```ts +source: null +``` + +`ICreateFacetModalPayload.initialValues` расширить опциональным `source`, чтобы future flows могли открывать create modal с заранее выбранным source. + +## Mapper + +Обновить `FacetFormInput` и `mapFacetFormToCreateInput()`: + +```ts +return { + label: values.label.trim(), + slug: normalizeFacetSlug(values.slug), + facetType: values.facetType, + uiType: values.uiType, + selectionMode: getDefaultFacetSelectionMode(values.uiType), + sources: values.source + ? [{ handle: values.source.handle, name: values.source.name }] + : [], +}; +``` + +Важно: не генерировать source на frontend из одного `facetType`. Источник истины для `handle/name` - `facetSourceCandidates`. + +## Ошибки и empty states + +Picker: + +- loading: стандартный AG Grid loading; +- empty search: `No sources found`; +- API error: показать alert/toast и оставить modal открытой; +- disabled confirm, пока нет выбранной строки. + +Create modal: + +- если `facetCreate` вернул ошибку по `sources`, привязать её к `source`; +- если ошибка по `facetType`, тоже подсветить source-control, потому type выбирается через source. + +## Файлы для реализации + +Основные: + +- `admin/src/domains/inventory/facets/graphql/queries.ts`; +- `admin/src/domains/inventory/facets/graphql/operation-types.ts`; +- `admin/src/domains/inventory/facets/hooks/use-facet-source-candidates.ts`; +- `admin/src/domains/inventory/facets/pickers/facet-source-picker-config.ts`; +- `admin/src/domains/inventory/facets/modals/create-facet-modal/schema.ts`; +- `admin/src/domains/inventory/facets/modals/create-facet-modal/create-facet-modal.tsx`; +- `admin/src/domains/inventory/facets/mappers/facet-input.mapper.ts`; +- `admin/src/domains/inventory/facets/modals.ts`; +- `admin/src/domains/modals.tsx`, только если нужен dedicated `FacetSourcePickerModal`. + +Опционально: + +- shared `usePageQuery`, если он ещё не существует в admin; +- dedicated source cell renderer, если `EntityCellRenderer` недостаточно похож на screenshot. + +## Проверка + +По проектным правилам не запускать `test` и `tsc`. + +Минимальная проверка после реализации: + +1. Запустить codegen, если GraphQL operation types генерируются из documents. +2. Запустить build, когда нужна новая версия кода. +3. Вручную проверить create flow: + - открыть `Create facet`; + - открыть `Select source`; + - поискать `color`; + - выбрать option source; + - убедиться, что label input сохранил введённый label; + - submit отправляет `sources`. + +## Открытые решения + +1. Оставлять generic modal title `Select Sources` или делать dedicated `Select source`. +2. Разрешать ли фильтр по `FacetType` внутри picker-а пользователю, или type должен задаваться только через caller `queryMeta`. +3. Нужен ли preselect source для current `typedPayload.initialValues.facetType`, если create modal открыт из action `Add same type`. diff --git a/admin/docs/feature-groups-plan.md b/admin/docs/feature-groups-plan.md index 7eeb0f004..d7bedcc23 100644 --- a/admin/docs/feature-groups-plan.md +++ b/admin/docs/feature-groups-plan.md @@ -42,7 +42,7 @@ export const productFeature = pgTable( "product_feature", { id: uuid("id").primaryKey().defaultRandom(), - projectId: uuid("project_id").notNull(), + storeId: uuid("store_id").notNull(), productId: uuid("product_id") .notNull() .references(() => product.id, { onDelete: "cascade" }), diff --git a/admin/docs/feature-sync-simplification-plan.md b/admin/docs/feature-sync-simplification-plan.md index 1b523fa86..3ff4bdfb3 100644 --- a/admin/docs/feature-sync-simplification-plan.md +++ b/admin/docs/feature-sync-simplification-plan.md @@ -110,7 +110,7 @@ export const productFeature = inventorySchema.table( "product_feature", { id: uuid("id").primaryKey(), - projectId: uuid("project_id").notNull(), + storeId: uuid("store_id").notNull(), productId: uuid("product_id") .notNull() .references(() => product.id, { onDelete: "cascade" }), @@ -149,7 +149,7 @@ export const productFeatureValue = inventorySchema.table( "product_feature_value", { id: uuid("id").primaryKey(), - projectId: uuid("project_id").notNull(), + storeId: uuid("store_id").notNull(), featureId: uuid("feature_id") .notNull() .references(() => productFeature.id, { onDelete: "cascade" }), @@ -399,7 +399,7 @@ export class FeaturesSyncScript extends BaseScript Promise; +} +``` + +## Inline editing + +Editable fields: + +- `onHand` +- `unavailable` + +Read-only fields: + +- `reserved` +- `available` +- rows without `inventoryItemId` +- all rows when default warehouse is missing + +Flow: + +1. User edits `onHand` or `unavailable`. +2. `handleCellEditRequest` parses integer input and keeps the existing FE `validateFieldChange` behavior. +3. UI stores pending change in `useInventoryEditStore`. +4. `displayData` merges API rows with pending edits. +5. `available` preview recalculates as `onHand - unavailable - reserved`. +6. Floating save/discard panel appears. +7. Save maps pending row edits to one `ProductBulkUpdateInput`, grouped by product. +8. Inventory-local save hook generates a fresh idempotency key and calls `catalogMutation.productBulkUpdate` with `X-Idempotency-Key`. +9. If the mutation returns `userErrors` and no job, pending edits stay in the store and the UI shows the errors. +10. If the mutation returns a job, clear pending edits and refetch variants opportunistically. Do not wait for job completion. + +FE keeps the current `validateFieldChange` consistency guard for inline edits: edits that make `onHand`, `unavailable`, or calculated `available` invalid are rejected before entering the pending edit store. Backend remains the final authority during save and may return additional `userErrors`. + +## Edit store migration + +`useInventoryEditStore` must be extended before wiring the real save flow. The current store only tracks `edits` and `status`; API-backed bulk submit needs request/row error state for validation and submit-start failures, but must not model job progress or final job item state. + +Add store state: + +```ts +type InventorySubmitError = { + message: string; + code?: string | null; + field?: readonly string[] | null; +}; + +interface InventoryEditStore { + edits: Record; + rowErrors: Record; + submitErrors: InventorySubmitError[]; + status: "idle" | "saving"; +} +``` + +Add or equivalent store actions: + +- `setRowErrors(rowId, errors)` stores mapper/local validation errors for one row; +- `setSubmitErrors(errors)` stores mutation `userErrors` or runtime submit errors that are not safely attributable to a row; +- `clearRowErrors(rowId)` clears errors when the row is edited again or discarded; +- `clearSubmitErrors()` clears request-level submit errors; +- `finishSaving()` sets `status` back to `"idle"` without clearing all edits; +- `onSubmitAccepted()` clears all edits and all stored errors after `productBulkUpdate` returns a job. + +Rules: + +- `setFieldValue` clears `rowErrors[itemId]` and request-level submit errors when the user changes a row after a failed submit; +- `discardItem` clears both `edits[itemId]` and `rowErrors[itemId]`; +- `discardAll` clears all edits, row errors, submit errors, and resets status; +- failed submit-start must not call `onSubmitAccepted()`; +- a returned job must call `onSubmitAccepted()`, because job completion tracking is out of scope; +- the floating panel may show aggregated error text, but stored row/request errors remain the source of truth for failed submit-start state. + +## Save mapping + +Add inventory-local mapper: + +```text +admin/src/domains/inventory/inventory/mappers/inventory-variant-edit.mapper.ts +``` + +Input: + +- current `InventoryVariantRow[]` +- pending edits from `useInventoryEditStore` +- default warehouse id + +Output: + +```ts +ApiProductBulkUpdateInput +``` + +Mutation input: + +```ts +{ + products: [ + { + productId: row.productId, + operations: { + variants: [ + { + variantId: row.variantId, + inventory: { + warehouseId: row.warehouseId ?? defaultWarehouse.id, + onHand: nextOnHand, + unavailable: nextUnavailable, + }, + }, + ], + }, + }, + ], +} +``` + +Rules: + +- send only changed rows; +- group changed rows by `productId`; +- create one `ProductBulkUpdateItem` per product; +- create one `VariantUpdateInput` per changed row under `operations.variants`; +- send only `variantId`, `inventory.warehouseId`, `inventory.onHand`, and `inventory.unavailable`; +- do not send SKU, cost, weight, dimensions, media, options, product content, product status, `reserved`, or `available`; +- do not send `expectedRevision` unless `InventoryVariants` is expanded to fetch `Product.revision`; +- enforce Product Bulk Update API limits before submit: max 100 products and max 500 operations total; +- if pending edits exceed API limits, keep edits and show a validation error instead of creating multiple jobs in this plan; +- skip rows without `inventoryItemId` and report them as unsavable, although the UI should normally prevent edits for these read-only rows; +- do not reuse product variant inventory mapper if it carries product-editor behavior or UI stock consistency validation. + +Submit behavior: + +- save hook returns submit-accepted state: `{ jobId, status }` plus mutation `userErrors`; +- save hook generates a fresh idempotency key for each submit attempt and passes it as the `X-Idempotency-Key` header via Apollo mutation context; +- retrying after a submit-start failure is a new submit attempt and gets a new idempotency key; +- no row is considered successfully applied until the async job runs, and this page does not track that state; +- submit-start failure keeps all edits and stores returned errors; +- submit accepted clears all edits and refetches the active `InventoryVariants` query opportunistically; +- do not query `productBulkUpdateJob`, `productBulkUpdateJobs`, or `BulkUpdateItem` from this page. + +## Module changes + +Target structure: + +```text +admin/src/domains/inventory/inventory/ + graphql/ + fragments.ts + queries.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-inventory.ts + use-inventory-edit-store.ts + use-inventory-variants.ts + use-save-inventory-variant-edits.ts + index.ts + mappers/ + inventory-variant-row.mapper.ts + inventory-variant-edit.mapper.ts + index.ts + page/ + page.tsx + components/ +``` + +Implementation steps: + +1. Add inventory-local GraphQL fragments, `InventoryVariants`, default warehouse query, and `InventoryProductBulkUpdate` mutation document that calls `catalogMutation.productBulkUpdate`. +2. Add operation response/variable types based on Admin API types used by the frontend. +3. Add or reuse shared API-backed Relay cursor pagination hook/component for table pages. +4. Add `useInventoryVariants` with variants query, default warehouse query, explicit `orderBy`, shared Relay pagination variables, loading/error handling, and row mapping. Do not put inventory-local cursor stack/range logic into this hook. +5. Add row mapper from `VariantEdge` to `InventoryVariantRow`. +6. Add sort mapper from AG Grid sort state to `VariantOrderByInput[]`, always prepending `{ field: "productId", direction: "asc" }`. +7. Add edit mapper to `ApiProductBulkUpdateInput`. +8. Migrate `useInventoryEditStore` to track submit-start errors and clear edits only when `productBulkUpdate` returns a job. +9. Add inventory-local save hook for `productBulkUpdate` submit, including per-submit `X-Idempotency-Key`, without job polling or job item tracking. +10. Replace mock-backed `useInventory` usage in the page with `useInventoryVariants`. +11. Replace `IInventoryListItem` typing in inventory table components with `InventoryVariantRow`. +12. Replace static pagination with the shared Relay cursor pagination layer wired to `pageInfo`/`totalCount`. +13. Disable sort UI on columns that do not map to `VariantOrderField`. +14. Remove imports from `@/mocks/inventory/inventory-list` from inventory page flow. + +## Deferred work + +- `FilterWidget` integration. +- Search UX. +- Filter UX. +- Sorting by inventory-owned fields such as SKU, on-hand, unavailable, reserved, available. +- Bulk `inventoryItemsUpdate` mutation. +- Bulk update job progress/polling UI. +- Partial row-level completion/error handling from `BulkUpdateItem`. +- Multiple-job fan-out for saves exceeding Product Bulk Update API limits. +- Multi-warehouse editing. +- Product-level inventory totals. +- Import/export actions. +- Delete selected rows action. +- Creating/backfilling missing inventory items from the inventory table. + +## Verification + +- Inventory page no longer imports mock inventory data. +- `InventoryVariants` passes `orderBy` on every request. +- `orderBy[0]` is always `{ field: "productId", direction: "asc" }`. +- AG Grid does not sort locally. +- Cursor pagination is driven by the shared API-backed Relay pagination hook/component, not inventory-local state. +- Next page sends `{ first: pageSize, after: pageInfo.endCursor }`. +- Previous page sends `{ last: pageSize, before: pageInfo.startCursor }`. +- Page size changes reset to first page. +- Cursor pagination resets to first page after sort changes. +- Sort is disabled while edits are pending. +- Pagination is disabled while edits are pending. +- Columns without `VariantOrderField` mapping are not sortable. +- Save calls `catalogMutation.productBulkUpdate`, not `inventoryMutation.inventoryItemUpdate`. +- Save sends `X-Idempotency-Key` on every `productBulkUpdate` submit. +- Save sends one `ProductBulkUpdateInput` grouped by product. +- Save sends only `products[].operations.variants[].variantId` and `inventory.{warehouseId,onHand,unavailable}` for edited rows. +- Save does not send SKU, cost, weight, dimensions, media, options, product-level fields, `reserved`, or `available`. +- Save enforces max 100 products and 500 operations before submit. +- Submit-start errors keep edits and are shown from store state. +- Returned job clears all edits and all stored submit errors. +- Inventory page does not query or poll `productBulkUpdateJob`, `productBulkUpdateJobs`, `BulkUpdateItem`, or progress. +- Editing or discarding a failed-submit row clears that row's stored errors. +- Run project-approved build only if code is implemented. +- Do not run `test` or `tsc` directly. diff --git a/admin/docs/inventory-widget-api-edit-variants-integration-plan.md b/admin/docs/inventory-widget-api-edit-variants-integration-plan.md new file mode 100644 index 000000000..8e27624ff --- /dev/null +++ b/admin/docs/inventory-widget-api-edit-variants-integration-plan.md @@ -0,0 +1,782 @@ +# План интеграции Inventory Widget API и Edit Inventory Variants Modal + +## Цель + +Подключить карточку Inventory в product details к реальному Admin GraphQL API и сделать сохранение из `EditVariantsModal` API-backed по той же frontend-архитектуре, которая уже используется для Pricing widget: + +- widget data загружается через отдельный GraphQL query hook; +- UI-компоненты получают generated API types из `@/graphql/types`; +- модалка редактирования variants переиспользуется в restricted mode через `availableColumns`; +- save flow возвращает `false` при API-ошибках, чтобы модалка оставалась открытой; +- после успешного сохранения обновляются product details, variants connection и inventory widget. + +## Текущий baseline + +- Pricing widget уже подключен через `PRODUCT_PRICING_WIDGET_QUERY`, `useProductPricingWidget`, `PricingBlock`, `useProductVariantsLoader` и `useUpdateProduct`. +- `InventorySection` сейчас получает `stats` из `productDetailsMockData.inventory`. +- `InventorySection` уже открывает `EditVariantsModal` с inventory columns, но `onSave` возвращает mock-сообщение `Variant inventory updates are not API-backed yet`. +- Gateway schema и generated Admin types уже содержат: + - `widgetQuery.inventory(productId: ID!): ProductInventoryWidget`; + - `inventoryMutation.inventoryItemUpdate(input: InventoryItemUpdateInput!): InventoryItemUpdatePayload!`; + - `ApiProductInventoryWidget`; + - `ApiInventoryItemUpdateInput`. +- `InventoryItemUpdateInput` обновляет `InventoryItem` по `InventoryItem.id`, а не по `Variant.id`. +- Stock update требует `warehouseId`, поэтому product-level inventory edit не должен неявно притворяться, что редактирует агрегат по всем складам. + +## Архитектурные правила + +- Backend Admin GraphQL остаётся в service-owned boundary: Inventory widget и inventory item mutations принадлежат `services/inventory`. +- Frontend GraphQL operations для products module должны жить в `admin/src/domains/inventory/products/graphql`. +- Hooks должны владеть `useQuery`/`useMutation`, loading/error/refetch и нормализацией API errors. +- Components не должны создавать API-output view models; использовать generated API types напрямую. +- Mappers конвертируют UI editor rows в API input, но не выполняют GraphQL calls. +- Не использовать `catalogMutation.productUpdate` для inventory-owned fields. Для inventory fields использовать `inventoryMutation.inventoryItemUpdate`. +- Проверки реализации: не запускать `test` и `tsc`; при необходимости запускать build/codegen через Shopana CLI. + +## API-контракты + +### Inventory widget query + +Добавить frontend operation рядом с pricing query: + +```graphql +query ProductInventoryWidget($productId: ID!) { + widgetQuery { + inventory(productId: $productId) { + quantities { + availableForSale + onHand + reserved + unavailable + } + availableChange7d + skuStatus { + total + lowStock { + count + averageDays + } + outOfStock { + count + averageDays + } + backorder { + count + averageDays + } + } + backorder { + quantity + etaAvgDays + } + alertThreshold { + method + minimumStock + } + } + } +} +``` + +`widgetQuery.inventory` в Admin generated types nullable, поэтому frontend operation type должен явно моделировать `inventory: ApiProductInventoryWidget | null`, а UI должен трактовать `null` как no-data state, не как API error. + +### Inventory item update mutation + +Добавить frontend operation: + +```graphql +mutation InventoryItemUpdate($input: InventoryItemUpdateInput!) { + inventoryMutation { + inventoryItemUpdate(input: $input) { + inventoryItem { + ...InventoryItemFields + } + userErrors { + ...UserErrorFields + } + } + } +} +``` + +Важно: mutation должна возвращать обновлённый `inventoryItem`, но нельзя полагаться только на Apollo cache для пришивания `InventoryItem` к `Variant`, если до save `variant.inventoryItem` был `null`. Явные `onProductRefresh`, `refetchVariants` и `refetchInventoryWidget` остаются обязательными. + +### Default warehouse query + +Для stock updates нужен warehouse scope. Первый API-backed вариант должен редактировать default warehouse: + +```graphql +query InventoryDefaultWarehouse { + inventoryQuery { + warehouses(first: 1, where: { isDefault: { _eq: true } }) { + edges { + node { + id + code + name + isDefault + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +} +``` + +Hook берёт первый edge из уже отфильтрованного результата; если default отсутствует, блокирует save и показывает понятную ошибку. Не сохранять stock без явного warehouse. + +Не использовать `totalCount` для определения наличия default warehouse: текущий repository может считать `totalCount` без учёта `where`. Единственный источник для выбора default warehouse в hook — `edges[0]?.node`. + +### Inventory item by variant query + +Для variants без `inventoryItem` перед открытием inventory modal нужно явно гидрировать inventory items через существующий lazy-create API: + +```graphql +query InventoryItemByVariant($variantId: ID!) { + inventoryQuery { + inventoryItemByVariant(variantId: $variantId) { + ...InventoryItemFields + } + } +} +``` + +Эта query имеет side effect в backend (`inventoryItemByVariant` создаёт item при отсутствии), поэтому использовать её только в explicit edit preparation flow, а не в passive render Inventory card. + +## Backend plan + +### 1. Зафиксировать Global ID contract для `widgetQuery.inventory` + +Файл: `services/inventory/src/resolvers/admin/InventoryWidgetResolver.ts` + +Сейчас resolver передаёт `productId` напрямую в repository. Нужно декодировать GraphQL global Product ID так же, как pricing resolver декодирует `Variant.id`: + +- импортировать `decodeGlobalIdByType` и `GlobalIdEntity`; +- в `WidgetQueryResolver.inventory(args)` декодировать `args.productId` как `GlobalIdEntity.Product`; +- передавать UUID в `InventoryWidgetResolver`. + +Ожидаемый результат: `InventoryWidgetRepository.getWidget(productId)` получает UUID, совместимый с `catalogVariant.productId`. + +### 2. Расширить GraphQL stock fields для modal baseline + +Файлы: + +- `services/inventory/src/api/graphql-admin/schema/stock.graphql`; +- `services/inventory/src/resolvers/admin/StockResolver.ts`; +- `admin/src/domains/inventory/products/graphql/fragments.ts`. + +Для корректной загрузки и сравнения inventory rows editor должен знать не только `quantityOnHand`, но и warehouse + unavailable/reserved values. Добавить в `WarehouseStock` поля: + +- `warehouseId: ID!`; +- `variantId: ID!`; +- `reservedQuantity: Int!`; +- `unavailableQuantity: Int!`; +- `availableForSale: Int!`. + +В resolver: + +- `reservedQuantity` возвращает `reservedQty`; +- `unavailableQuantity` возвращает `unavailableQty`; +- `availableForSale` возвращает `quantityOnHand - reservedQty - unavailableQty`; +- IDs должны соответствовать принятому GraphQL ID contract. + +### 3. Привести `Warehouse.id` к mutation contract + +Файлы: + +- `services/inventory/src/resolvers/admin/WarehouseResolver.ts`; +- `services/inventory/src/resolvers/admin/QueryResolver.ts`; +- `services/inventory/src/api/graphql-admin/resolvers/types.ts`; +- `services/inventory/src/resolvers/admin/MutationResolver.ts`. + +Сейчас `inventoryItemUpdate` декодирует `stock.warehouseId` как `GlobalIdEntity.Warehouse`, но `WarehouseResolver.id()` возвращает raw UUID. Нужно выбрать один контракт и применить его последовательно. + +Предпочтительный вариант по federation pattern: + +- `WarehouseResolver.id()` возвращает `encodeGlobalIdByType(this.$props, GlobalIdEntity.Warehouse)`; +- `StockResolver.warehouseId()` тоже возвращает global Warehouse ID; +- `StockResolver.id()` кодирует stock record UUID как `GlobalIdEntity.WarehouseStock`; +- `WarehouseStock.id` входит в global ID boundary и возвращается как `gid://shopana/WarehouseStock/`; +- `WarehouseStock.warehouseId` и новый `WarehouseStock.variantId` должны быть global IDs, потому что они указывают на существующие GraphQL entities `Warehouse` и `Variant`; +- `inventoryQuery.warehouse(id:)` декодирует `GlobalIdEntity.Warehouse` перед loader/repository access; +- `inventoryQuery.node(id:)` пробует декодировать `GlobalIdEntity.Warehouse`, `GlobalIdEntity.InventoryItem` и `GlobalIdEntity.WarehouseStock` перед загрузкой соответствующей entity; +- `Warehouse.__resolveReference` декодирует global Warehouse ID перед `WarehouseResolver.load`; +- `InventoryItem.__resolveReference` декодирует `GlobalIdEntity.InventoryItem` перед `InventoryItemResolver.load`, потому что `InventoryItemResolver.id()` уже возвращает global ID; +- mutation продолжает декодировать Warehouse ID; +- `warehouseUpdate(input.id)` и `warehouseDelete(input.id)` декодируют global Warehouse ID перед вызовом scripts; +- `warehouseDelete.deletedWarehouseId` возвращается как global Warehouse ID, чтобы payload соответствовал GraphQL ID boundary. +- если используются GraphQL filters по ID (`WarehouseWhereInput.id`, `WarehouseStockWhereInput.id`, `warehouseId`, `variantId`), декодировать global IDs перед repository query либо явно задокументировать, что эти low-level filters временно принимают raw UUID и не входят в UI flow первого шага; +- добавить explicit normalize/decode layer для stock filters перед repository access. В частности, `WarehouseResolver.stock(args)` сейчас сам инжектит raw `warehouseId` в `where` filter для repository query. После перевода внешнего GraphQL boundary на global IDs нужно разделить: + - injected internal constraints (`this.$props` warehouse UUID) остаются raw UUID; + - user-provided GraphQL filters (`args.where.warehouseId`, `args.where.variantId`, `args.where.id`) нормализуются до raw UUID перед merge/execute; + - resolver не должен смешивать global IDs и raw UUID в одном `where._and`, иначе часть nested paths `Warehouse.stock(...)` начнёт возвращать пустые данные. + +Альтернатива только для совместимости: mutation использует safe decode и принимает raw UUID. Этот вариант хуже, потому что ослабляет GraphQL ID boundary. + +### 4. Усилить `inventoryItemUpdate` server-side safety + +Файл: `services/inventory/src/resolvers/admin/MutationResolver.ts` + +Сейчас resolver содержит stock/SKU/cost business logic напрямую и не проверяет result `repository.stock.applyStockChange(...)`. Repository может вернуть `REJECTED`, но mutation всё равно вернёт success payload. + +Перед frontend save flow нужно убрать риск partial save. `inventoryItemUpdate` должен выполняться как единый transactional use case, где stock/SKU/cost/physical changes из одного mutation input либо применяются вместе, либо возвращают `userErrors` без частичных side effects. + +Предпочтительный вариант: перенести `inventoryItemUpdate` на существующий `InventoryItemUpdateScript`/script-level flow, чтобы использовать script validation/result handling и transactional boundary. При этом resolver сохраняет внешний GraphQL contract, но явно адаптирует GraphQL input к script params: + +- декодирует `input.id` как `GlobalIdEntity.InventoryItem`; +- загружает `InventoryItem` по decoded item ID; +- использует `item.variantId` как raw `variantId` для script; +- декодирует `input.stock.warehouseId` как `GlobalIdEntity.Warehouse` только если stock branch есть в input; +- после success возвращает `InventoryItemResolver(item.id, ctx)`, а не stock/script result как основной GraphQL payload. + +Текущий `InventoryItemUpdateScript` stock-oriented: он принимает `variantId`, обязательные `warehouseId`/`onHand` и не покрывает весь `InventoryItemUpdateInput`. Кроме того, простая замена resolver logic на текущий script не даёт полной атомарности всего mutation input: script сначала применяет stock branch, а затем пишет SKU/weight/cost. Перед использованием в `inventoryItemUpdate` нужно выбрать один из двух безопасных вариантов: + +- расширить script params под optional SKU/stock/cost/physical branches и сделать этот script единым `@Transactional()` use case для всего `InventoryItemUpdateInput`; +- оставить script только для stock branch, но обернуть `inventoryItemUpdate` в отдельный `@Transactional()` use case, который сначала pre-validates SKU/cost/physical/stock, затем применяет writes без риска частичного save. + +В выбранном варианте все validations должны выполняться до первого write: + +- existence checks для `InventoryItem` и `Warehouse`; +- SKU uniqueness; +- quantity constraints для `onHand`/`unavailable`; +- validation cost currency/amount; +- validation dimensions/weight; +- stock delta calculation и проверка, что stock change не будет `REJECTED`. + +Если stock rejection можно узнать только через `applyStockChange`, этот call должен выполняться внутри той же transaction boundary, что и остальные writes, и при `REJECTED` весь use case должен вернуть `userErrors` без commit SKU/cost/physical changes. Нельзя полагаться на текущий порядок writes в `InventoryItemUpdateScript` как на atomic flow. + +Нельзя оставлять flow, где SKU обновляется до `applyStockChange`, а затем stock rejection возвращает ошибку при уже сохранённом SKU. + +### 5. Не добавлять bulk backend mutation на первом шаге + +Первый API-backed save может вызывать `inventoryItemUpdate` по одному разу на изменённый row и агрегировать результат в frontend hook. Это соответствует текущему backend API и не требует нового `inventoryItemsUpdate`. + +Отдельный bulk mutation стоит добавлять позже, если: + +- нужно атомарное сохранение всех rows; +- появятся реальные performance issues; +- нужно единое server-side validation summary. + +Если backend mutation будет существенно дорабатываться, перенести бизнес-логику в script по project pattern вместо расширения resolver большим количеством логики. + +### 6. Codegen и schema compose + +После backend schema изменений: + +1. Запустить codegen для inventory service через Shopana CLI. +2. Обновить composed Admin schema для `admin/schema.graphql`. +3. Запустить Admin GraphQL codegen, чтобы обновить `admin/src/graphql/types.ts`. +4. Не редактировать generated files вручную. + +## Frontend plan + +### 1. Добавить GraphQL operation types + +Файл: `admin/src/domains/inventory/products/graphql/operation-types.ts` + +Добавить types на основе generated API types: + +- `ProductInventoryWidgetQueryData`; +- `ProductInventoryWidgetQueryVariables`; +- `InventoryDefaultWarehouseQueryData`; +- `InventoryDefaultWarehouseQueryVariables`; +- `InventoryItemByVariantQueryData`; +- `InventoryItemByVariantQueryVariables`; +- `InventoryItemUpdateMutationData`; +- `InventoryItemUpdateMutationVariables`. + +Не re-export generated schema types из module barrels. + +Для `ProductInventoryWidgetQueryData` не сужать `widgetQuery.inventory` до non-null: generated schema contract допускает `null`, и hook/UI должны сохранять эту nullable shape. + +### 2. Добавить queries + +Файл: `admin/src/domains/inventory/products/graphql/queries.ts` + +Добавить: + +- `PRODUCT_INVENTORY_WIDGET_QUERY`; +- `INVENTORY_DEFAULT_WAREHOUSE_QUERY`; +- `INVENTORY_ITEM_BY_VARIANT_QUERY`. + +Запросы должны использовать `widgetQuery.inventory`, `inventoryQuery.warehouses` и `inventoryQuery.inventoryItemByVariant`, не mock data. + +### 3. Добавить mutation + +Файл: `admin/src/domains/inventory/products/graphql/mutations.ts` + +Добавить `INVENTORY_ITEM_UPDATE_MUTATION` с `InventoryItemFields` и `UserErrorFields`. + +Важно: mutation должна возвращать обновлённый `inventoryItem`, но Apollo cache не гарантирует автоматическое пришивание `InventoryItem` к cached `Variant`, если до save `variant.inventoryItem` был `null`. Явные refresh/refetch из save flow остаются обязательными. + +### 4. Добавить hook `useProductInventoryWidget` + +Новый файл: `admin/src/domains/inventory/products/hooks/use-product-inventory-widget.ts` + +Contract: + +- input: `productId: string | null`, `skip?: boolean`; +- output: `data: ApiProductInventoryWidget | null`, `isLoading`, `error`, `refetch`; +- `fetchPolicy: "cache-and-network"`; +- `skipToken` использовать при отсутствии `productId`; +- возвращать `previousData` как pricing hook, чтобы карточка не мигала при refresh. +- если query успешно вернула `inventory: null`, hook возвращает `data: null` и `error: null`; `InventorySection` показывает no-data state. + +### 5. Добавить hook `useDefaultWarehouse` + +Новый файл: `admin/src/domains/inventory/products/hooks/use-default-warehouse.ts` + +Contract: + +- грузит `warehouses(first: 1, where: { isDefault: { _eq: true } })`; +- возвращает `defaultWarehouse: ApiWarehouse | null` из `edges[0]?.node`; +- если default отсутствует, возвращает `defaultWarehouse: null`; save должен быть blocked с понятной ошибкой; +- не возвращать первый не-default warehouse через основное поле hook, потому что его легко случайно передать в save flow; +- если позже нужен read-only fallback для отображения, вернуть его отдельным полем (`fallbackWarehouse`/`firstWarehouse`) и не использовать для mutations; +- возвращает `defaultWarehouse`, `loading`, `error`, `refetch`. + +### 6. Добавить hook `useEnsureVariantInventoryItems` + +Новый файл: `admin/src/domains/inventory/products/hooks/use-ensure-variant-inventory-items.ts` + +Contract: + +```ts +ensureVariantInventoryItems(variants: ApiVariant[]): Promise +``` + +Поведение: + +- найти variants без `inventoryItem`; +- для каждого missing item вызвать `INVENTORY_ITEM_BY_VARIANT_QUERY` с `variant.id`; +- вернуть `ApiVariant[]` той же API shape, но с заполненным `inventoryItem` для hydrated variants; +- если hydration падает или API не возвращает item, вернуть error; `InventorySection` показывает сообщение и не открывает modal; +- не переносить этот вызов в mapper, потому что mappers не выполняют GraphQL calls; +- использовать hook только в explicit `Edit inventory` preparation flow, чтобы lazy-create side effect не происходил при обычном render. + +### 7. Добавить hook `useUpdateInventoryItems` + +Новый файл: `admin/src/domains/inventory/products/hooks/use-update-inventory-items.ts` + +Contract: + +```ts +updateInventoryItems(inputs: ApiInventoryItemUpdateInput[]): Promise<{ + inventoryItems: ApiInventoryItem[]; + userErrors: ApiGenericUserError[]; + errors: ApiGenericUserError[]; +}> +``` + +Поведение: + +- если inputs пустой, mutation не вызывается; +- для первого implementation можно выполнять mutations последовательно, чтобы ошибки легче маппились к rows; +- собрать все `userErrors`; +- transport/runtime error превращать в `UNEXPECTED_ERROR`; +- вернуть единый result для `InventorySection`. + +Не класть сравнение editor rows в hook; hook получает уже готовые API inputs. + +### 8. Добавить mapper для inventory rows + +Новый файл: `admin/src/domains/inventory/products/mappers/product-variant-inventory.mapper.ts` + +Функция: + +```ts +prepareChangedVariantInventoryInputs({ + rows, + variants, + warehouseId, + defaultCurrency, +}): ApiInventoryItemUpdateInput[] +``` + +Правила: + +- `row.id` сопоставлять с `ApiVariant.id`; +- брать `variant.inventoryItem?.id` как `input.id`; +- если `inventoryItem` отсутствует после pre-modal hydration, не строить update silently и вернуть/throw user-facing error; +- `sku` отправлять только если изменился; +- `stock` отправлять только если изменились `onHand` или `unavailable`; +- если отправляется `stock`, всегда включать оба значения `onHand` и `unavailable`: backend трактует отсутствующий `unavailable` как `0`, поэтому нельзя отправлять только изменённое вложенное поле; +- `reserved` не отправлять, потому что reserved управляется order/reservation flow; +- `available` не отправлять, потому что это calculated field; +- `barcode` не отправлять, потому что текущий API contract не содержит barcode; +- `unitCost` отправлять только если inventory mode будет включать `costPrice`; иначе не включать в первый scope; +- валидировать non-negative numeric values перед mutation. +- текущий frontend validator запрещает negative `available` (`onHand - unavailable - reserved < 0`). Первый API-backed inventory edit должен явно сохранить это ограничение, либо отдельно менять validator/UX, если backorder/continue-selling flow должен позволять отрицательную доступность. + +### 9. Обновить modal payload и variant mapper для stock per warehouse + +Файлы: + +- `admin/src/domains/inventory/products/modals.ts`; +- `admin/src/domains/inventory/products/components/variants/edit-variants-modal.tsx`; +- `admin/src/domains/inventory/products/mappers/product-variant-editor.mapper.ts`. + +Сейчас `onHand` берётся из `inventoryItem.totalAvailable`, что является агрегатом. Для API-backed edit нужно: + +- добавить в `IEditVariantsModalPayload` optional warehouse scope, например `inventoryWarehouseId?: string` или `variantEditorScope?: { type: "inventory"; warehouseId: string }`; +- не делать warehouse scope обязательным для всех modal usages, чтобы pricing flow и full variants editor продолжали работать без изменений; +- в `EditVariantsModal` передавать warehouse scope из payload в `mapApiVariantsToEditorInputs`; +- принять optional `warehouseId` или `warehouseSelector`; +- выбрать stock row текущего warehouse из `variant.inventoryItem.stock`; +- заполнить: + - `onHand` из `stock.quantityOnHand`; + - `unavailable` из `stock.unavailableQuantity`; + - `reserved` из `stock.reservedQuantity`; + - `available` вычисляется editor grid; +- если stock row отсутствует, использовать `0` значения, чтобы mutation создала/seed stock через backend flow. + +Для read-only display без warehouse можно продолжать использовать `totalAvailable`, но edit modal должен быть warehouse-scoped. + +### 10. Обновить `InventorySection` + +Файл: `admin/src/domains/inventory/products/components/product-details-card/sections/inventory-section.tsx` + +Изменения: + +- убрать обязательный prop `stats`; +- внутри секции использовать `useProductInventoryWidget({ productId: product.id })`; +- loading/no-data/error states строить от API hook; +- убрать `productDetailsMockData.inventory`; +- при `Edit inventory`: + - загрузить all variants через `useProductVariantsLoader`, как PricingBlock; + - перед открытием modal прогнать variants через `useEnsureVariantInventoryItems`, чтобы missing `inventoryItem` был lazy-created/loaded; + - использовать hydrated variants при открытии modal и в `handleSaveInventory` closure; + - загрузить default warehouse через `useDefaultWarehouse`; + - открыть `EditVariantsModal`; + - передать warehouse scope в modal payload (`inventoryWarehouseId`/`variantEditorScope.warehouseId`) из default warehouse; + - передать `availableColumns: ["sku", "onHand", "unavailable", "reserved", "available"]`; + - не показывать `barcode` в API-backed scope; + - `reserved` и `available` оставить readonly через существующую column config; + - `showColumnSettings: false`; + - `onSave` должен возвращать `Promise`. + +### 11. Save flow в `InventorySection` + +Поведение должно совпадать с pricing modal UX: + +1. `handleEditInventory` подготавливает editor data, гидрирует missing `inventoryItem` через `inventoryItemByVariant` и показывает loading на menu action. +2. `handleSaveInventory(rows, editorVariants)` строит changed inputs через mapper. +3. Если changes нет, показать neutral message и закрыть modal. +4. Если нет default warehouse, показать error и вернуть `false`. +5. Вызвать `updateInventoryItems(inputs)`. +6. Если есть API/user/runtime errors: + - показать первое полезное сообщение; + - вернуть `false`, чтобы modal осталась открытой. +7. После успешного save: + - `onProductRefresh?.()`; + - `refetchInventoryWidget()`; + - `refetchVariants()` или повторная загрузка variants connection; + - показать success message; + - вернуть `true`. + +`editorVariants` должны передаваться в save через closure, как в `PricingBlock`; общий `IEditVariantsModalPayload.onSave` не нужно расширять вторым аргументом ради inventory flow. + +### 12. Обновить `ProductDetailsCard` и `ProductModal` + +Файлы: + +- `admin/src/domains/inventory/products/components/product-details-card/product-details-card.tsx`; +- `admin/src/domains/inventory/products/modals/product-modal/product-modal.tsx`; +- `admin/src/domains/inventory/products/components/product-details-card/types.ts`. + +Изменения: + +- `InventorySection` больше не получает `supplementalData.inventory`; +- `ProductDetailsSupplementalData` можно оставить для reviews/attributes/bundles, но убрать inventory из обязательного contract или пометить как legacy mock-only; +- `ProductModal` продолжает передавать `onProductRefresh={refetch}`. + +### 13. Обновить barrels + +Файлы: + +- `admin/src/domains/inventory/products/hooks/index.ts`; +- `admin/src/domains/inventory/products/mappers/index.ts`; +- `admin/src/domains/inventory/products/graphql/index.ts`. + +Добавить exports для новых hooks/mappers/operations, но не re-export generated API types. + +## UX decisions + +- Inventory widget показывает product-level aggregate по всем variants/warehouses. +- Inventory edit modal в первом API-backed implementation редактирует stock default warehouse. Это должно быть ясно в internal implementation и, при необходимости, в modal title/secondary text. +- `reserved` readonly, потому что order/reservation subsystem владеет этим значением. +- `available` readonly, потому что вычисляется как `onHand - unavailable - reserved`. +- `barcode` исключить из API-backed modal, пока backend не добавит поле в `InventoryItem`. +- Если позже нужен multi-warehouse edit, добавить warehouse selector в header modal и перезаполнять rows для выбранного warehouse. + +## Порядок реализации + +Реализацию лучше разделить на два этапа/PR. Frontend не должен строиться поверх нестабильного GraphQL boundary: сначала нужно зафиксировать backend contracts, обновить schema и generated Admin types, затем подключать UI. + +### Этап 1 / PR 1: Backend contract hardening + +1. Исправить backend global ID handling для `widgetQuery.inventory(productId:)`. +2. Привести `Warehouse.id` и `inventoryItemUpdate.stock.warehouseId` к одному ID contract. +3. Привести `InventoryItem.__resolveReference` к global `InventoryItem.id` contract. +4. Добавить normalize/decode layer для Warehouse/Stock GraphQL ID filters перед repository access, включая nested `WarehouseResolver.stock(args)`. +5. Добавить `GlobalIdEntity.WarehouseStock` и кодировать `WarehouseStock.id` как global ID. +6. Расширить `WarehouseStock` GraphQL fields для `reservedQuantity`, `unavailableQuantity`, `availableForSale`, `warehouseId`, `variantId`. +7. Исправить `inventoryItemUpdate` как единый transactional use case, чтобы rejected stock changes возвращали `userErrors` и не сохраняли частичные SKU/cost/physical changes. +8. Запустить inventory service codegen через Shopana CLI. +9. Обновить composed Admin schema для `admin/schema.graphql`. +10. Запустить Admin GraphQL codegen, чтобы обновить `admin/src/graphql/types.ts`. +11. Запустить build через Shopana CLI, если нужна проверка новой backend/schema версии. + +### Этап 2 / PR 2: Frontend integration + +1. Добавить frontend queries/mutation/operation types на основе обновлённых generated Admin types. +2. Добавить `useProductInventoryWidget`. +3. Добавить строгий `useDefaultWarehouse` с `defaultWarehouse: ApiWarehouse | null`. +4. Добавить `useEnsureVariantInventoryItems` и query `inventoryItemByVariant` для pre-modal hydration missing inventory items. +5. Добавить `useUpdateInventoryItems`. +6. Добавить `product-variant-inventory.mapper.ts`. +7. Обновить modal payload, `EditVariantsModal` и `product-variant-editor.mapper.ts`, чтобы inventory edit был warehouse-scoped. +8. Переподключить `InventorySection` к API hook, pre-modal inventory item hydration и API save flow. +9. Убрать mock inventory из `ProductDetailsCard` contract. +10. Проверить, что pricing flow не изменился. +11. Запустить build через Shopana CLI, если нужна проверка новой frontend версии. + +## Acceptance criteria + +- Product details Inventory card загружает данные из `widgetQuery.inventory`, а не из mocks. +- `ProductInventoryWidgetQueryData` и `useProductInventoryWidget` сохраняют nullable contract `widgetQuery.inventory`; `inventory: null` ведёт к no-data state без ошибки. +- Inventory card показывает loading/error/no-data states без падений. +- Inventory card корректно отображает проценты/secondary text при `skuStatus.total === 0` без `NaN`/`Infinity`. +- `Edit inventory` открывает `EditVariantsModal` с API-backed variants. +- Перед открытием inventory modal variants без `inventoryItem` гидрируются через `inventoryQuery.inventoryItemByVariant`; если hydration не удалась, modal не открывается и пользователь видит ошибку. +- Default warehouse загружается серверным фильтром `warehouses(first: 1, where: { isDefault: { _eq: true } })`, без client-side поиска среди первых N warehouses. +- Hook default warehouse выбирает warehouse только из `edges[0]?.node` и не использует `totalCount` как признак наличия default warehouse. +- Modal показывает только поддержанные inventory columns: `sku`, `onHand`, `unavailable`, `reserved`, `available`. +- Inventory modal получает warehouse scope через payload и заполняет editable stock rows из `variant.inventoryItem.stock` для выбранного warehouse, а не из aggregate `inventoryItem.totalAvailable`. +- `reserved` и `available` нельзя редактировать. +- Save вызывает `inventoryMutation.inventoryItemUpdate` только для changed rows. +- Unchanged rows не отправляются. +- При stock update input всегда содержит оба поля `stock.onHand` и `stock.unavailable`, даже если изменилось только одно из них. +- `barcode` не отправляется в API. +- При API `userErrors` modal остаётся открытой. +- При backend `applyStockChange` rejection mutation возвращает `userErrors`, а не success. +- При backend `applyStockChange` rejection mutation не сохраняет частичные изменения из того же input (`sku`, `unitCost`, `dimensions`, `weight`). +- `inventoryItemUpdate` resolver адаптирует GraphQL `InventoryItem.id` к script-level raw `variantId` через loaded `InventoryItem.variantId`; script не получает `InventoryItem.id` как `variantId`. +- Если `inventoryItemUpdate` использует `InventoryItemUpdateScript`, script/transactional wrapper поддерживает mutation inputs без stock branch и не требует `warehouseId`/`onHand` для SKU-only updates. +- При успешном save modal закрывается, product details, variants и inventory widget обновляются. +- `widgetQuery.inventory` работает с global `Product.id` из Admin UI. +- Stock save использует согласованный Warehouse ID format. +- Все Warehouse GraphQL boundary points используют один global ID contract: `Warehouse.id`, `stock.warehouseId`, `inventoryQuery.warehouse`, `inventoryQuery.node/nodes`, `Warehouse.__resolveReference`, `warehouseUpdate`, `warehouseDelete`, `deletedWarehouseId`. +- `InventoryItem.__resolveReference` принимает global `InventoryItem.id` и декодирует его перед loader/repository access. +- `WarehouseStock.id` возвращается как global `WarehouseStock` ID. +- `WarehouseStock.warehouseId` и `WarehouseStock.variantId` возвращаются как global `Warehouse`/`Variant` IDs. + +## Verification + +- Не запускать `test` и `tsc` напрямую. +- После schema changes запускать только codegen/schema compose через Shopana CLI. +- Для финальной проверки новой версии кода запускать build через Shopana CLI. +- Минимальная ручная проверка: + - открыть product details; + - убедиться, что Inventory card делает GraphQL request `ProductInventoryWidget`; + - открыть `Edit inventory`; + - изменить `sku`, `onHand`, `unavailable`; + - сохранить; + - убедиться, что mutation request содержит `inventoryMutation.inventoryItemUpdate`; + - убедиться, что Inventory card и variant rows обновились после save. + +## Риски и открытые вопросы + +- Без default warehouse нельзя корректно сохранить product-level stock edit. Нужно либо требовать default warehouse, либо добавить warehouse selector. +- Текущий backend mutation не bulk/atomic. Если нужна атомарность всех rows, потребуется отдельный `inventoryItemsUpdate` script/mutation. +- Если `InventoryItem` для variant отсутствует, frontend должен сначала получить/создать его через `inventoryQuery.inventoryItemByVariant` или backend должен поддержать update by `variantId`. +- Если business ожидает редактировать cost/weight/dimensions из этой же modal, scope нужно расширять отдельными mapper branches и API inputs, но первый inventory scope лучше ограничить stock/SKU. + +## Строгий план выполнения + +Ниже порядок выполнения без перестановок. Не переходить к frontend фазам, пока backend schema, composed schema и generated Admin types не обновлены. + +### Фаза 1. Backend ID boundary + +1. В `services/inventory/src/resolvers/admin/InventoryWidgetResolver.ts` декодировать `widgetQuery.inventory(productId:)` через `decodeGlobalIdByType(args.productId, GlobalIdEntity.Product)`. +2. Передавать в `InventoryWidgetResolver` и `InventoryWidgetRepository.getWidget(productId)` только raw Product UUID. +3. В `services/inventory/src/resolvers/admin/WarehouseResolver.ts` изменить `WarehouseResolver.id()` на global Warehouse ID через `encodeGlobalIdByType(this.$props, GlobalIdEntity.Warehouse)`. +4. В `services/inventory/src/resolvers/admin/StockResolver.ts` изменить `warehouseId()` на global Warehouse ID через `GlobalIdEntity.Warehouse`. +5. В `services/inventory/src/resolvers/admin/StockResolver.ts` изменить `variantId()` на global Variant ID через `GlobalIdEntity.Variant`. +6. `StockResolver.id()` должен возвращать global `WarehouseStock` ID. +7. Wording schema/comment для `WarehouseStock.id` должен описывать global ID stock record. +8. В `services/inventory/src/resolvers/admin/QueryResolver.ts` декодировать `inventoryQuery.warehouse(id:)` как `GlobalIdEntity.Warehouse` перед loader/repository access. +9. В `inventoryQuery.node(id:)` пробовать decode/load Warehouse через `GlobalIdEntity.Warehouse`, InventoryItem через `GlobalIdEntity.InventoryItem`, WarehouseStock через `GlobalIdEntity.WarehouseStock`. +10. В `services/inventory/src/api/graphql-admin/resolvers/types.ts` декодировать `Warehouse.__resolveReference.id` как `GlobalIdEntity.Warehouse` перед `WarehouseResolver.load`. +11. В `services/inventory/src/api/graphql-admin/resolvers/types.ts` декодировать `InventoryItem.__resolveReference.id` как `GlobalIdEntity.InventoryItem` перед `InventoryItemResolver.load`. +12. В `services/inventory/src/resolvers/admin/MutationResolver.ts` декодировать `warehouseUpdate(input.id)` и `warehouseDelete(input.id)` как `GlobalIdEntity.Warehouse` перед script call. +13. Возвращать `warehouseDelete.deletedWarehouseId` как global Warehouse ID. + +### Фаза 2. Backend filter normalization + +1. Найти все resolver/repository entry points, где GraphQL `where` filters по `id`, `warehouseId`, `variantId` передаются в repository query. +2. Добавить helper для normalize/decode GraphQL ID filters перед repository access. +3. Для `WarehouseWhereInput.id` декодировать external value как `GlobalIdEntity.Warehouse`. +4. Для `WarehouseStockWhereInput.warehouseId` декодировать external value как `GlobalIdEntity.Warehouse`. +5. Для `WarehouseStockWhereInput.variantId` декодировать external value как `GlobalIdEntity.Variant`. +6. Для `WarehouseStockWhereInput.id` декодировать external value как `GlobalIdEntity.WarehouseStock`. +7. В `WarehouseResolver.stock(args)` оставить injected internal constraint `{ warehouseId: { _eq: this.$props } }` raw UUID. +8. В `WarehouseResolver.stock(args)` нормализовать только user-provided `args.where` до raw UUID до merge с injected internal constraint. +9. Проверить, что ни один `where._and` не смешивает global Warehouse/Variant IDs с raw UUID для одного repository query. + +### Фаза 3. Backend WarehouseStock schema fields + +1. В `services/inventory/src/api/graphql-admin/schema/stock.graphql` добавить `WarehouseStock.warehouseId: ID!`. +2. В `services/inventory/src/api/graphql-admin/schema/stock.graphql` добавить `WarehouseStock.variantId: ID!`. +3. В `services/inventory/src/api/graphql-admin/schema/stock.graphql` добавить `WarehouseStock.reservedQuantity: Int!`. +4. В `services/inventory/src/api/graphql-admin/schema/stock.graphql` добавить `WarehouseStock.unavailableQuantity: Int!`. +5. В `services/inventory/src/api/graphql-admin/schema/stock.graphql` добавить `WarehouseStock.availableForSale: Int!`. +6. В `services/inventory/src/resolvers/admin/StockResolver.ts` вернуть `reservedQuantity` из `reservedQty`. +7. В `services/inventory/src/resolvers/admin/StockResolver.ts` вернуть `unavailableQuantity` из `unavailableQty`. +8. В `services/inventory/src/resolvers/admin/StockResolver.ts` вернуть `availableForSale` как `quantityOnHand - reservedQty - unavailableQty`. +9. Не добавлять новый `GlobalIdEntity` для stock record в этой фазе. + +### Фаза 4. Backend transactional inventory item update + +1. Создать или расширить script/use case для полного `InventoryItemUpdateInput`. +2. Пометить выбранный script/use case `@Transactional()`. +3. В resolver оставить только GraphQL input adaptation и result mapping. +4. В resolver декодировать `input.id` как `GlobalIdEntity.InventoryItem`. +5. Загрузить `InventoryItem` по decoded item ID. +6. Использовать `item.variantId` как raw `variantId` для script/use case. +7. Если `input.stock` есть, декодировать `input.stock.warehouseId` как `GlobalIdEntity.Warehouse`. +8. До первого write выполнить existence validation для `InventoryItem`. +9. До первого write выполнить existence validation для `Warehouse`, если есть stock branch. +10. До первого write выполнить SKU uniqueness validation, если `sku` передан. +11. До первого write выполнить validation для `onHand` и `unavailable`. +12. До первого write выполнить validation для cost currency/amount, если `unitCost` передан. +13. До первого write выполнить validation для dimensions/weight, если они переданы. +14. До первого write вычислить stock delta. +15. Если stock rejection можно определить только через `applyStockChange`, выполнять `applyStockChange` внутри той же transaction boundary. +16. При `applyStockChange.status === "REJECTED"` вернуть `userErrors` и rollback всех SKU/cost/physical writes. +17. Поддержать mutation inputs без stock branch. +18. Поддержать SKU-only update без требования `warehouseId`/`onHand`. +19. После success вернуть `InventoryItemResolver(item.id, ctx)`. +20. Убедиться, что resolver больше не игнорирует result `applyStockChange`. + +### Фаза 5. Backend generation and schema compose + +1. Запустить inventory service codegen через Shopana CLI. +2. Обновить composed Admin schema `admin/schema.graphql` через Shopana CLI. +3. Запустить Admin GraphQL codegen через Shopana CLI. +4. Не редактировать generated files вручную. +5. Не запускать `test` и `tsc`. +6. Запустить build через Shopana CLI только если нужна проверка новой backend/schema версии. + +### Фаза 6. Frontend GraphQL operations + +1. В `admin/src/domains/inventory/products/graphql/fragments.ts` обновить `InventoryItemFields.stock` новыми `WarehouseStock` fields. +2. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `ProductInventoryWidgetQueryData`. +3. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `ProductInventoryWidgetQueryVariables`. +4. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `InventoryDefaultWarehouseQueryData`. +5. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `InventoryDefaultWarehouseQueryVariables`. +6. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `InventoryItemByVariantQueryData`. +7. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `InventoryItemByVariantQueryVariables`. +8. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `InventoryItemUpdateMutationData`. +9. В `admin/src/domains/inventory/products/graphql/operation-types.ts` добавить `InventoryItemUpdateMutationVariables`. +10. В `admin/src/domains/inventory/products/graphql/queries.ts` добавить `PRODUCT_INVENTORY_WIDGET_QUERY`. +11. В `admin/src/domains/inventory/products/graphql/queries.ts` добавить `INVENTORY_DEFAULT_WAREHOUSE_QUERY`. +12. В `admin/src/domains/inventory/products/graphql/queries.ts` добавить `INVENTORY_ITEM_BY_VARIANT_QUERY`. +13. В `admin/src/domains/inventory/products/graphql/mutations.ts` добавить `INVENTORY_ITEM_UPDATE_MUTATION`. +14. Не re-export generated API types из module barrels. + +### Фаза 7. Frontend hooks + +1. Добавить `admin/src/domains/inventory/products/hooks/use-product-inventory-widget.ts`. +2. В `useProductInventoryWidget` использовать `skipToken`, если `productId` отсутствует. +3. В `useProductInventoryWidget` использовать `fetchPolicy: "cache-and-network"`. +4. В `useProductInventoryWidget` возвращать `previousData`, чтобы карточка не мигала при refresh. +5. В `useProductInventoryWidget` трактовать `inventory: null` как no-data, не как error. +6. Добавить `admin/src/domains/inventory/products/hooks/use-default-warehouse.ts`. +7. В `useDefaultWarehouse` запрашивать только `warehouses(first: 1, where: { isDefault: { _eq: true } })`. +8. В `useDefaultWarehouse` возвращать `defaultWarehouse: ApiWarehouse | null` только из `edges[0]?.node`. +9. Не возвращать первый не-default warehouse через основное поле hook. +10. Добавить `admin/src/domains/inventory/products/hooks/use-ensure-variant-inventory-items.ts`. +11. В `useEnsureVariantInventoryItems` вызывать `inventoryItemByVariant` только для variants без `inventoryItem`. +12. В `useEnsureVariantInventoryItems` использовать explicit edit preparation flow, не passive render. +13. В `useEnsureVariantInventoryItems` ограничить concurrency или выполнять hydration последовательно, чтобы lazy-create side effects не запускались массово без контроля. +14. Добавить `admin/src/domains/inventory/products/hooks/use-update-inventory-items.ts`. +15. В `useUpdateInventoryItems` выполнять mutations последовательно. +16. В `useUpdateInventoryItems` агрегировать `userErrors`. +17. В `useUpdateInventoryItems` превращать runtime/transport errors в `UNEXPECTED_ERROR`. + +### Фаза 8. Frontend mappers and modal scope + +1. Добавить `admin/src/domains/inventory/products/mappers/product-variant-inventory.mapper.ts`. +2. В `prepareChangedVariantInventoryInputs` сопоставлять `row.id` с `ApiVariant.id`. +3. В `prepareChangedVariantInventoryInputs` использовать `variant.inventoryItem.id` как `input.id`. +4. Если `inventoryItem` отсутствует после hydration, возвращать user-facing error. +5. Отправлять `sku` только если изменился. +6. Отправлять `stock` только если изменились `onHand` или `unavailable`. +7. Если отправляется `stock`, всегда включать `stock.onHand` и `stock.unavailable`. +8. Не отправлять `reserved`. +9. Не отправлять `available`. +10. Не отправлять `barcode`. +11. Не отправлять `unitCost` в первом inventory scope, если `costPrice` не включён в modal. +12. Валидировать non-negative numeric values до mutation. +13. Сохранить frontend constraint `onHand - unavailable - reserved >= 0`. +14. В `admin/src/domains/inventory/products/modals.ts` добавить optional warehouse scope в `IEditVariantsModalPayload`. +15. В `EditVariantsModal` передать warehouse scope в `mapApiVariantsToEditorInputs`. +16. В `product-variant-editor.mapper.ts` выбирать stock row по warehouse scope. +17. Заполнять `onHand` из `stock.quantityOnHand`. +18. Заполнять `unavailable` из `stock.unavailableQuantity`. +19. Заполнять `reserved` из `stock.reservedQuantity`. +20. Если stock row отсутствует, использовать `0` для `onHand`/`unavailable`/`reserved`. +21. Сохранить pricing flow без warehouse scope изменений. + +### Фаза 9. Frontend InventorySection integration + +1. Убрать обязательный prop `stats` из `InventorySection`. +2. Внутри `InventorySection` вызвать `useProductInventoryWidget({ productId: product.id })`. +3. Построить loading state из hook. +4. Построить error state из hook. +5. Построить no-data state при `data === null` без hook error. +6. Убрать использование `productDetailsMockData.inventory`. +7. Добавить preparing state для action `Edit inventory`. +8. При `Edit inventory` загрузить all variants через `useProductVariantsLoader`. +9. Затем прогнать variants через `useEnsureVariantInventoryItems`. +10. Затем получить `defaultWarehouse` из `useDefaultWarehouse`. +11. Если `defaultWarehouse === null`, показать error и не открывать modal. +12. Открыть `EditVariantsModal` с hydrated variants. +13. Передать warehouse scope из `defaultWarehouse.id`. +14. Передать `availableColumns: ["sku", "onHand", "unavailable", "reserved", "available"]`. +15. Передать `showColumnSettings: false`. +16. Не показывать `barcode`. +17. Оставить `reserved` readonly. +18. Оставить `available` readonly. +19. Реализовать `handleSaveInventory(rows, editorVariants)` через closure. +20. В save построить inputs через `prepareChangedVariantInventoryInputs`. +21. Если changes нет, показать neutral message и вернуть `true`. +22. Вызвать `updateInventoryItems(inputs)`. +23. Если есть `userErrors`/runtime errors, показать первую полезную ошибку и вернуть `false`. +24. После success вызвать `onProductRefresh?.()`. +25. После success вызвать `refetchInventoryWidget()`. +26. После success вызвать `refetchVariants()` или повторную загрузку variants connection. +27. После success показать success message и вернуть `true`. + +### Фаза 10. Frontend cleanup and verification + +1. Обновить `ProductDetailsCard`, чтобы он не передавал inventory mock в `InventorySection`. +2. Обновить `ProductDetailsSupplementalData`, чтобы inventory не был обязательным API-backed contract. +3. Обновить `ProductModal`, сохранив `onProductRefresh={refetch}`. +4. Обновить barrels для новых hooks/mappers/operations. +5. Не re-export generated API types из barrels. +6. Проверить вручную, что pricing edit modal открывается и сохраняет прежним flow. +7. Проверить вручную, что product details Inventory card делает GraphQL request `ProductInventoryWidget`. +8. Проверить вручную, что `Edit inventory` открывает modal с API-backed variants. +9. Проверить вручную, что save вызывает `inventoryMutation.inventoryItemUpdate`. +10. Проверить вручную, что API `userErrors` оставляют modal открытой. +11. Проверить вручную, что после success обновляются product details, variants и inventory widget. +12. Не запускать `test` и `tsc`. +13. Запустить build через Shopana CLI, если нужна финальная проверка новой frontend версии. diff --git a/admin/docs/inventory-widget-plan.md b/admin/docs/inventory-widget-plan.md index ea7012d07..8a5dfe6ec 100644 --- a/admin/docs/inventory-widget-plan.md +++ b/admin/docs/inventory-widget-plan.md @@ -22,7 +22,7 @@ ALTER TABLE inventory.warehouse_stock ADD CONSTRAINT warehouse_stock_unavailable_check CHECK (unavailable_qty >= 0), ADD CONSTRAINT warehouse_stock_unavailable_le_onhand CHECK (unavailable_qty <= quantity_on_hand); --- Убедиться, что есть UNIQUE (project_id, warehouse_id, variant_id). +-- Убедиться, что есть UNIQUE (store_id, warehouse_id, variant_id). -- -- Backorder семантика: -- quantity_on_hand >= 0 всегда (физический остаток не может быть отрицательным) @@ -57,7 +57,7 @@ CREATE TYPE inventory.stock_apply_status AS ENUM ('APPLIED', 'REJECTED'); CREATE TABLE inventory.stock_changes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), seq BIGINT GENERATED ALWAYS AS IDENTITY, -- монотонный порядок, решает created_at-гонки - project_id UUID NOT NULL, + store_id UUID NOT NULL, variant_id UUID NOT NULL REFERENCES inventory.variant(id) ON DELETE CASCADE, warehouse_id UUID NOT NULL, @@ -119,7 +119,7 @@ CREATE TABLE inventory.stock_changes ( created_by UUID, apply_status inventory.stock_apply_status NOT NULL DEFAULT 'APPLIED', - -- FK только на warehouse_id (project_id денормализован для запросов) + -- FK только на warehouse_id (store_id денормализован для запросов) CONSTRAINT stock_changes_warehouse_fk FOREIGN KEY (warehouse_id) REFERENCES inventory.warehouses(id) ON DELETE CASCADE ); @@ -132,16 +132,16 @@ CREATE INDEX idx_stock_changes_variant_created_seq ON inventory.stock_changes(variant_id, created_at DESC, seq DESC); CREATE INDEX idx_stock_changes_variant_warehouse_created_seq ON inventory.stock_changes(variant_id, warehouse_id, created_at DESC, seq DESC); -CREATE INDEX idx_stock_changes_project_seq ON inventory.stock_changes(project_id, seq DESC); +CREATE INDEX idx_stock_changes_store_seq ON inventory.stock_changes(store_id, seq DESC); CREATE INDEX idx_stock_changes_type_seq ON inventory.stock_changes(movement_type, seq DESC); CREATE INDEX idx_stock_changes_reason_seq ON inventory.stock_changes(reason, seq DESC); -- Идемпотентность CREATE UNIQUE INDEX idx_stock_changes_idempotency - ON inventory.stock_changes(project_id, source_system, source_event_id, warehouse_id, variant_id); + ON inventory.stock_changes(store_id, source_system, source_event_id, warehouse_id, variant_id); -- Индекс для быстрого lookup без variant_id/warehouse_id (проверка "был ли event вообще") CREATE INDEX idx_stock_changes_idempo_lookup - ON inventory.stock_changes(project_id, source_system, source_event_id); + ON inventory.stock_changes(store_id, source_system, source_event_id); -- Индекс для availableChange7d и других time-based запросов ``` @@ -166,26 +166,26 @@ WITH -- 1. Идемпотентность: INSERT с UNIQUE constraint, только один поток получит id ins AS ( INSERT INTO inventory.stock_changes ( - project_id, warehouse_id, variant_id, + store_id, warehouse_id, variant_id, delta_on_hand, delta_reserved, delta_unavailable, movement_type, reason, transfer_direction, source_system, source_event_id, correlation_id, note, created_by, on_hand_after, reserved_after, unavailable_after -- placeholder ) SELECT - $project_id, $warehouse_id, $variant_id, + $store_id, $warehouse_id, $variant_id, $delta_on_hand, $delta_reserved, $delta_unavailable, $movement_type, $reason, $transfer_direction, $source_system, $source_event_id, $correlation_id, $note, $created_by, 0, 0, 0 -- placeholder (0>=0, 0>=0, 0>=0, 0<=0 — проходит CHECK) - ON CONFLICT (project_id, source_system, source_event_id, warehouse_id, variant_id) + ON CONFLICT (store_id, source_system, source_event_id, warehouse_id, variant_id) DO NOTHING RETURNING id ), existing AS ( SELECT id FROM inventory.stock_changes - WHERE project_id = $project_id + WHERE store_id = $store_id AND source_system = $source_system AND source_event_id = $source_event_id AND warehouse_id = $warehouse_id @@ -195,12 +195,12 @@ existing AS ( -- 2. UPSERT warehouse_stock: только если ins вставился И результат валиден up AS ( INSERT INTO inventory.warehouse_stock ( - project_id, warehouse_id, variant_id, + store_id, warehouse_id, variant_id, quantity_on_hand, reserved_qty, unavailable_qty ) -- INSERT-ветка (строки нет): вставляем дельты как итог, но только если валидно SELECT - $project_id, $warehouse_id, $variant_id, + $store_id, $warehouse_id, $variant_id, $delta_on_hand, $delta_reserved, $delta_unavailable FROM ins WHERE @@ -209,7 +209,7 @@ up AS ( AND $delta_unavailable >= 0 AND $delta_unavailable <= $delta_on_hand - ON CONFLICT (project_id, warehouse_id, variant_id) DO UPDATE SET + ON CONFLICT (store_id, warehouse_id, variant_id) DO UPDATE SET -- UPDATE-ветка (строка есть): инкремент от текущего значения (нет lost-update) quantity_on_hand = inventory.warehouse_stock.quantity_on_hand + $delta_on_hand, reserved_qty = inventory.warehouse_stock.reserved_qty + $delta_reserved, @@ -249,7 +249,7 @@ reject AS ( apply_status = 'REJECTED' FROM ins LEFT JOIN inventory.warehouse_stock ws - ON ws.project_id = sc.project_id + ON ws.store_id = sc.store_id AND ws.warehouse_id = sc.warehouse_id AND ws.variant_id = sc.variant_id WHERE sc.id = ins.id @@ -311,7 +311,7 @@ CREATE TYPE inventory.reservation_status AS ENUM ('ACTIVE', 'RELEASED', 'FULFILL CREATE TABLE inventory.reservations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - project_id UUID NOT NULL, + store_id UUID NOT NULL, variant_id UUID NOT NULL REFERENCES inventory.variant(id), warehouse_id UUID NOT NULL, @@ -325,10 +325,10 @@ CREATE TABLE inventory.reservations ( reserved_at TIMESTAMPTZ DEFAULT NOW(), released_at TIMESTAMPTZ, - -- project_id в UNIQUE для изоляции между проектами - UNIQUE(project_id, order_system, order_id, variant_id, warehouse_id), + -- store_id в UNIQUE для изоляции между проектами + UNIQUE(store_id, order_system, order_id, variant_id, warehouse_id), - -- FK только на warehouse_id (project_id денормализован) + -- FK только на warehouse_id (store_id денормализован) CONSTRAINT reservations_warehouse_fk FOREIGN KEY (warehouse_id) REFERENCES inventory.warehouses(id) ON DELETE CASCADE ); @@ -346,7 +346,7 @@ CREATE INDEX idx_reservations_order ON inventory.reservations(order_system, orde ```sql CREATE TABLE inventory.product_inventory_settings ( - project_id UUID NOT NULL, + store_id UUID NOT NULL, product_id UUID NOT NULL REFERENCES inventory.product(id) ON DELETE CASCADE, alert_threshold_method VARCHAR(20) NOT NULL DEFAULT 'SAFETY_STOCK', alert_minimum_stock INTEGER NOT NULL DEFAULT 10, @@ -355,7 +355,7 @@ CREATE TABLE inventory.product_inventory_settings ( backorder_max_qty INTEGER, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (project_id, product_id) + PRIMARY KEY (store_id, product_id) ); ``` @@ -364,7 +364,7 @@ CREATE TABLE inventory.product_inventory_settings ( ```sql CREATE TABLE inventory.inbound_supply ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - project_id UUID NOT NULL, + store_id UUID NOT NULL, variant_id UUID NOT NULL REFERENCES inventory.variant(id), warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id), @@ -379,7 +379,7 @@ CREATE TABLE inventory.inbound_supply ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(project_id, source_type, source_id, variant_id, warehouse_id) + UNIQUE(store_id, source_type, source_id, variant_id, warehouse_id) ); CREATE INDEX idx_inbound_supply_variant_date @@ -571,13 +571,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; -- Seed-записи для существующих остатков -- source_event_id должен быть <= 128 символов, используем короткий хеш INSERT INTO inventory.stock_changes ( - project_id, warehouse_id, variant_id, + store_id, warehouse_id, variant_id, delta_on_hand, delta_reserved, delta_unavailable, on_hand_after, reserved_after, unavailable_after, movement_type, source_system, source_event_id, created_at ) SELECT - ws.project_id, + ws.store_id, ws.warehouse_id, ws.variant_id, 0, 0, 0, -- delta=0 разрешено для SEED @@ -587,7 +587,7 @@ SELECT 'SEED', 'MIGRATION', -- Детерминированный короткий ключ с префиксом seed: (5 + 64 hex = 69 символов < 128) - 'seed:' || encode(digest(concat_ws(':', ws.project_id::text, ws.warehouse_id::text, ws.variant_id::text), 'sha256'), 'hex'), + 'seed:' || encode(digest(concat_ws(':', ws.store_id::text, ws.warehouse_id::text, ws.variant_id::text), 'sha256'), 'hex'), COALESCE(ws.updated_at, ws.created_at, NOW()) FROM inventory.warehouse_stock ws; ``` @@ -599,7 +599,7 @@ FROM inventory.warehouse_stock ws; -- deficit = max(0, reserved_qty + unavailable_qty - quantity_on_hand) WITH deficit AS ( SELECT - ws.project_id, + ws.store_id, ws.variant_id, ws.warehouse_id, GREATEST(ws.reserved_qty + ws.unavailable_qty - ws.quantity_on_hand, 0) as deficit diff --git a/admin/docs/orders-ui-migration-plan.md b/admin/docs/orders-ui-migration-plan.md new file mode 100644 index 000000000..c7015b56e --- /dev/null +++ b/admin/docs/orders-ui-migration-plan.md @@ -0,0 +1,1128 @@ +# План переноса Orders UI из `admin-old` в `admin` (Next) + +## 1. Цель + +Перенести страницу списка заказов и весь рабочий UI заказа из +`admin-old/admin/src/modules/orders` в Next-приложение `admin`, сохранив +поведение старого интерфейса и адаптировав реализацию к текущей архитектуре +Admin. + +Результат переноса должен включать: + +- рабочую страницу `All Orders` вместо `EmptySectionPage`; +- таблицу заказов на AG Grid с `usePageConfig`; +- Relay-пагинацию, серверную сортировку, поиск и обязательный набор фильтров; +- одну основную модалку для создания и редактирования заказа; +- все вложенные order-модалки через modal stack; +- временный API-контракт в форме будущего GraphQL API; +- mock-транспорт, который реализует этот контракт без зависимости UI от mock-данных; +- Zod-валидацию, input/error mappers и optimistic concurrency через `version`; +- автоматическое обновление списка после успешного сохранения. + +План выровнен с: + +- `knowledge/vault/patterns/admin-graphql-layer.md`; +- существующими модулями `admin/src/domains/customers/all-customers` и + `admin/src/domains/customer-content/reviews`; +- текущими `DataLayout`, `FilterWidget`, `usePageConfig`, `CursorPagination` и + modal stack; +- существующей регистрацией Sales domain в `admin/src/domains/sales`. + +## 2. Ключевое архитектурное решение + +### 2.1. Сохранение старой order view + +Старые компоненты работают не с сырым ответом GraphQL, а с собственной +агрегированной моделью `IOrder`, создаваемой классом `Order.create`. В ней уже +собраны: + +- `customerDetails`; +- `paymentSummary`; +- `orderItems` с `product`, `fulfillmentQuantity`, ценой, весом и себестоимостью; +- `fulfillments` с вложенными order items и shipping item; +- `paymentItem`; +- `productsInfo`; +- `customerStatistic`; +- `events`, `tags`, адреса и методы оплаты/доставки. + +На этом этапе семантическую форму этой view нужно сохранить. Перенос не должен +одновременно перерабатывать UI под текущий backend orders API: это отдельная +последующая задача. + +При этом новую реализацию не следует снова строить на entity-классах и runtime +маппинге ответа API в UI-view. Временная сохранённая view оформляется как сам +контракт будущего GraphQL UI API: + +- `ApiOrder` повторяет нужную UI структуру старого `IOrder`; +- list/detail hooks возвращают `ApiOrder` напрямую; +- mocks сразу создаются в форме `ApiOrder`; +- компоненты не вызывают `Order.create` и не получают отдельный output view + model; +- mappers используются только для преобразования form state в mutation input и + `userErrors` в ошибки формы. + +Иными словами, сохраняется структура старой view, но удаляется старый механизм +`GraphQL response -> entity class -> UI view`. + +### 2.2. Допустимая техническая нормализация + +GraphQL `DateTime` в UI-контракте хранится как ISO string, а не `Date`. Это не +меняет смысл старой view и соответствует правилам нового Admin. Преобразование в +`Date` выполняется только на границе отображения через formatter. + +Денежные поля на первом этапе сохраняют старую семантику и единицу измерения. +Нельзя молча менять major/minor units во время визуального переноса. В контракте +и mock-данных нужно явно документировать единицу, а форматирование вынести в +один helper. Переход на Money object или minor units выполняется вместе с +реальным API отдельным изменением. + +### 2.3. Граница транспорта + +UI не должен знать, что данные замоканы: + +```text +page/modal + -> domain hook + -> api/request-orders.ts + -> временный in-memory mock repository +``` + +При подключении GraphQL заменяется реализация request-функций или hooks, но не +props компонентов, формы, таблица и modal payloads. Hooks не импортируют seed +data напрямую. + +## 3. Текущий baseline + +### 3.1. Next Admin + +Сейчас `admin/src/domains/sales/all-orders/page/page.tsx` рендерит только: + +```tsx + +``` + +Маршрут уже зарегистрирован: + +```text +/:orgName/:storeName/orders +``` + +Но item `all-orders-list` в `admin/src/domains/sales/register.tsx` имеет +`disabled: true`. + +### 3.2. Старый Orders UI + +Основные точки входа: + +- `admin-old/admin/src/modules/orders/components/Orders.tsx` — таблица; +- `admin-old/admin/src/modules/orders/components/Edit.tsx` — drawer редактора; +- `admin-old/admin/src/modules/orders/hooks/mutations.ts` — все операции; +- `admin-old/admin/src/modules/orders/graphql/findMany.ts` — list query; +- `admin-old/admin/src/modules/orders/graphql/findOne.ts` — details query; +- `admin-old/admin/src/entity/Order/*` — старая UI-view и её сборка. + +В старом router и `DrawerModuleMap` Orders уже закомментирован. Источником +поведения остаётся код модуля, а не его текущая доступность через старый route. + +### 3.3. Реестр переносимого UI + +| Старый компонент | Назначение | Целевое место | +| --- | --- | --- | +| `Orders.tsx` | список, сортировка, фильтры, create/open | `page/page.tsx` | +| `Edit.tsx` | основная карточка заказа | `modals/order-modal/` | +| `DraftFulfillment.tsx` | позиции draft-заказа | `components/fulfillment/` | +| `ActiveFulfillment.tsx` | active fulfillment | `components/fulfillment/` | +| `FulfillmentMenu.tsx` | действия fulfillment | `components/fulfillment/` | +| `TrackingInfo.tsx` | tracking display | `components/shipping/` | +| `ActivePaymentSummary.tsx` | состояние оплаты | `components/payment/` | +| `DraftPaymentSummary.tsx` | расчёт draft оплаты | `components/payment/` | +| `PaymentActions.tsx` | действия оплаты | `components/payment/` | +| `PaymentSummaryData.tsx` | суммы заказа | `components/payment/` | +| `OrderStatusAndInfo.tsx` | статус и метаданные | `components/status/` | +| `Customer.tsx` | связь с клиентом | `components/customer/` | +| `ContactInfo.tsx` | snapshot контактов | `components/customer/` | +| `ShippingDetails.tsx` | адрес и метод доставки | `components/details/` | +| `PaymentDetails.tsx` | billing address и payment method | `components/details/` | +| `Tags.tsx` | теги | `components/moderation/` | +| `EditableNote.tsx` | admin note | `components/moderation/` | +| `TimeLine.tsx` | события и комментарии | `components/activity/` | +| `QntPopover.tsx` | изменение количества | `components/items/` | +| `WeightPopover.tsx` | изменение веса | `components/items/` | +| `CostPricePopover.tsx` | изменение себестоимости | `components/items/` | +| `StatusSummary.tsx` | агрегированное состояние | `components/status/` | +| `Price.tsx` | вывод денег | `components/money/` или local utility | + +`OrderDetails/Old.tsx` не переносится. Это альтернативная старая реализация +деталей, не используемая текущим `Edit.tsx`. Перенос двух редакторов создаст +расходящиеся сценарии и лишнюю поддержку. + +## 4. Целевая структура domain-модуля + +Orders является самостоятельным модулем внутри Sales domain: + +```text +admin/src/domains/sales/all-orders/ + api/ + request-orders.ts + order-mock-repository.ts + order-mock-seed.ts + graphql/ + fragments.ts + queries.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-orders.ts + use-order.ts + use-create-order.ts + use-update-order.ts + use-delete-order.ts + use-cancel-order.ts + use-order-editor-context.ts + use-update-order-status.ts + use-update-payment-status.ts + use-update-fulfillment-status.ts + use-update-order-customer.ts + use-update-order-tags.ts + use-update-admin-note.ts + use-add-order-comment.ts + use-order-items.ts + use-shipping-item.ts + index.ts + mappers/ + order-form.mapper.ts + order-errors.mapper.ts + order-status-input.mapper.ts + fulfillment-input.mapper.ts + shipping-input.mapper.ts + index.ts + components/ + activity/ + customer/ + details/ + fulfillment/ + items/ + money/ + moderation/ + payment/ + shipping/ + status/ + modals/ + order-modal/ + order-modal.tsx + schema.ts + sections/ + relations-section.tsx + content-section.tsx + additional-data-section.tsx + activity-section.tsx + complaints-moderation-section.tsx + index.ts + order-status-modal/ + payment-status-modal/ + fulfillment-status-modal/ + shipping-item-modal/ + shipping-details-modal/ + payment-details-modal/ + page/ + filter-schema.ts + page-config.ts + page.tsx + modals.ts +``` + +Правила структуры: + +- GraphQL documents и operation types принадлежат модулю, а не общему + `sales/graphql`; +- hooks инкапсулируют loading/error/result и не отдают сырой вложенный payload; +- `api/` является временной реализацией транспорта, но использует те же + operation types, что и будущий GraphQL; +- `mappers/` не преобразует query output; +- визуальные компоненты не импортируют `api/` и seed data; +- компоненты, нужные только одной модалке, остаются внутри её каталога; +- публичные импорты модуля ограничиваются `page`, hooks и `modals.ts`. + +## 5. Временный GraphQL-shaped API контракт + +### 5.1. Основные output types + +До появления generated schema types временные типы объявляются в +`graphql/operation-types.ts`. Имена должны совпадать с ожидаемыми GraphQL +сущностями, чтобы последующая замена была механической. + +Минимальный контракт: + +```ts +export interface ApiOrder { + id: string; + version: number; + orderNumber: number; + status: OrderStatus; + createdAt: string; + updatedAt: string; + adminNote: string | null; + externalSystemId: string | null; + currencyCode: string; + displayCurrencyCode: string | null; + displayExchangeRate: number | null; + customer: ApiOrderCustomer | null; + customerDetails: ApiOrderCustomerDetails; + customerStatistic: ApiOrderCustomerStatistic | null; + billingAddress: ApiOrderAddress | null; + shippingAddress: ApiOrderAddress | null; + paymentMethod: ApiOrderPaymentMethod | null; + shippingMethod: ApiOrderShippingMethod | null; + orderItems: ApiOrderItem[]; + productsInfo: ApiOrderProductInfo[]; + fulfillments: ApiOrderFulfillment[]; + paymentItem: ApiOrderPaymentItem | null; + paymentSummary: ApiOrderPaymentSummary; + events: ApiOrderEvent[]; + tags: ApiOrderTag[]; +} +``` + +`ApiOrderItem` сохраняет старые поля: + +```ts +export interface ApiOrderItem { + id: string; + price: number; + quantity: number; + originalQuantity: number; + fulfillmentQuantity: number | null; + totalAmount: number; + subtotalAmount: number; + taxAmount: number | null; + discountAmount: number | null; + productCostPrice: number | null; + weight: ApiOrderWeight | null; + product: ApiOrderProductInfo; + createdAt: string; +} +``` + +`ApiOrderFulfillment.orderItems` также остаётся агрегированной UI-view. Это +осознанный временный контракт для сохранения старого UI. + +### 5.2. Relay connection + +Список возвращает connection: + +```ts +export interface OrderEdge { + cursor: string; + node: ApiOrder; +} + +export interface OrderConnection { + edges: OrderEdge[]; + pageInfo: ApiPageInfo; + totalCount: number; +} +``` + +List operation variables: + +```ts +export interface OrdersQueryVariables { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: OrderWhereInput | null; + orderBy?: OrderOrderByInput[] | null; +} +``` + +Не добавлять page-number pagination. Mock должен реально интерпретировать +cursor arguments, включая backward pagination через `last`/`before`. + +### 5.3. Enums + +Сохраняются статусы старого UI: + +- order: `DRAFT`, `ACTIVE`, `COMPLETED`, `CANCELLED`, `ARCHIVED`; +- payment: `PENDING`, `PAID`, `CANCELLED`; +- fulfillment: `PENDING`, `PROCESSING`, `ON_HOLD`, `SHIPPED`, `DELIVERED`, + `RETURNED`, `CANCELLED`, `FULFILLED`. + +Enums не должны содержать React nodes. Labels, colors и danger-state находятся +в `components/status/status-config.tsx` или `page/page-config.ts`, но не в API +типах. + +### 5.4. Query documents + +Несмотря на mock transport, нужно сразу создать документы будущих операций: + +- `ORDERS_QUERY`; +- `ORDER_QUERY`; +- focused fragments `OrderListFields` и `OrderDetailsFields`; +- общий `PageInfoFields` и `UserErrorFields`, если нет подходящего shared + fragment. + +`OrderListFields` включает только поля таблицы. `OrderDetailsFields` включает +полную сохранённую view, необходимую основной модалке. + +### 5.5. Mutation contracts + +Каждая mutation возвращает payload единого вида: + +```ts +export interface OrderMutationPayload { + order: ApiOrder | null; + userErrors: OrderUserError[]; +} + +export interface OrderUserError { + code: string; + field?: string | null; + message: string; +} +``` + +Нужны следующие inputs/operations: + +- `OrderCreateInput` / `ORDER_CREATE_MUTATION`; +- `OrderUpdateInput` / `ORDER_UPDATE_MUTATION`; +- `OrderDeleteInput` / `ORDER_DELETE_MUTATION`; +- `OrderCancelInput` / `ORDER_CANCEL_MUTATION`; +- `OrderStatusUpdateInput`; +- `OrderPaymentStatusUpdateInput`; +- `OrderFulfillmentStatusUpdateInput`; +- `OrderCustomerUpdateInput` и customer detach; +- `OrderTagsUpdateInput`; +- `OrderAdminNoteUpdateInput`; +- `OrderCommentAddInput`; +- `OrderItemAddInput`; +- `OrderItemUpdateInput`; +- `OrderItemDeleteInput`; +- `OrderFulfillmentSplitInput` и undo split; +- `OrderShippingItemCreateInput`; +- `OrderShippingItemUpdateInput`. + +Каждый update input содержит `id` и `expectedVersion`. Для вложенной сущности +дополнительно передаётся её id, но проверяется версия родительского заказа. + +## 6. Mock transport + +### 6.1. Repository behavior + +`order-mock-repository.ts` хранит нормальный изменяемый массив `ApiOrder[]` и +предоставляет request-функции. Он должен имитировать backend, а не просто +возвращать fixtures. + +Обязательное поведение: + +- искусственная асинхронная задержка; +- filtering по `where`; +- multi-column sorting по `orderBy`; +- forward/backward Relay pagination; +- `totalCount` до применения pagination; +- поиск через `_or` условия; +- уникальный `orderNumber` при создании; +- увеличение `version` и `updatedAt` после каждой mutation; +- добавление timeline event после mutation; +- проверка разрешённых status transitions; +- возврат field-level `userErrors`; +- конфликт `VERSION_CONFLICT`, если `expectedVersion !== order.version`; +- immutable copies на выходе, чтобы UI не мутировал repository по ссылке. + +### 6.2. Seed data + +Seed должен покрывать не менее 30 заказов, чтобы работала pagination, и включать: + +- все order/payment/fulfillment statuses; +- draft и active заказы; +- guest order без `customer`; +- заказ без shipping/payment method; +- несколько fulfillments; +- split fulfillment с `parentId`; +- shipping item с tracking code и без него; +- разные валюты; +- скидки, налоги и shipping amount; +- пустые и заполненные admin note/tags; +- timeline с системными событиями и комментариями; +- адреса разных стран; +- ошибки/граничные значения, например quantity 1 и частично fulfilled item. + +### 6.3. Request layer + +`request-orders.ts` экспортирует функции, повторяющие будущие GraphQL use cases: + +```ts +requestOrders(variables) +requestOrder(id) +requestCreateOrder(input) +requestUpdateOrder(input) +requestDeleteOrder(input) +requestCancelOrder(input) +requestUpdateOrderStatus(input) +requestUpdatePaymentStatus(input) +requestUpdateFulfillmentStatus(input) +// ...остальные действия +``` + +Hooks зависят только от этих функций и operation types. Seed/repository не +импортируются за пределами `api/`. + +### 6.4. Замена mocks на GraphQL + +Позже подключение API выполняется в следующем порядке: + +1. добавить соответствующие operations в реальную schema/codegen; +2. заменить временные types generated types из `@/graphql/types`; +3. заменить request-вызовы на Apollo в hooks; +4. сохранить return contracts hooks; +5. убрать `order-mock-repository.ts` и seed; +6. не менять props таблицы и модалок, если реальный API выполняет сохранённый + контракт. + +Если реальный API не вернёт сохранённую агрегированную view, это будет отдельная +осознанная миграция контракта. Нельзя скрывать расхождения новым output mapper в +этом модуле. + +## 7. Страница списка заказов + +### 7.1. Page composition + +`page/page.tsx` является client component и использует: + +- `DataLayout`; +- `FilterWidget`; +- `AgGridReact`; +- `useAgGridTheme`; +- `usePageConfig`; +- `CursorPagination`; +- `useOrders`; +- `useOrderModal`. + +После завершения страницы `disabled: true` удаляется только у +`all-orders-list`. Остальные Sales placeholders этим переносом не активируются. + +### 7.2. Операционные колонки + +В таблице показываются только данные, необходимые оператору для быстрого +решения. Рекомендуемый default набор: + +| Колонка | Поле/источник | Sort | +| --- | --- | --- | +| Order | `orderNumber`, `createdAt` | `ORDER_NUMBER`, `CREATED_AT` | +| Customer | `customerDetails` | `CUSTOMER_NAME` или отключён до API support | +| Items | первые product thumbnails + count | без сортировки | +| Total | `paymentSummary.totalAmount`, `currencyCode` | `TOTAL_AMOUNT` | +| Order status | `status` | `STATUS` | +| Payment | `paymentItem.status` или `PENDING` | `PAYMENT_STATUS` | +| Fulfillment | агрегат `fulfillments[].status` | `FULFILLMENT_STATUS` | +| Delivery | shipping method/tracking indicator | без сортировки | +| Updated | `updatedAt` | `UPDATED_AT` | + +Подробные адреса, billing method, tags, все customer fields и полная товарная +информация не включаются в default grid. Они доступны в order modal. Это +уменьшает горизонтальную перегрузку старой таблицы. + +Default sort: `UPDATED_AT DESC`, затем `ORDER_NUMBER DESC` для стабильности. + +`defaultColDef.comparator` возвращает `0`, потому что сортировка серверная. +`onSortChanged` передаётся из `usePageConfig`. + +### 7.3. Поиск + +`buildOrderSearchCondition(search)` создаёт `_or` по: + +- order number/external id; +- customer email; +- customer phone; +- customer first/last/full name; +- tracking code. + +Mock repository обязан обработать этот shape. Поиск сбрасывает cursor на первую +страницу через `usePageConfig`. + +### 7.4. Required filters + +`page/filter-schema.ts` обязан содержать операционно необходимый набор: + +- order status; +- payment status; +- fulfillment status; +- created date range; +- updated date range; +- customer/customer id; +- customer email; +- customer phone; +- order number; +- total amount/price; +- currency; +- shipping country; +- shipping method; +- payment method; +- tags; +- tracking presence или tracking code. + +Под `required filters` понимается обязательная доступность этих фильтров в +schema страницы, а не требование выбрать значение перед первым запросом. + +Для price/date filters использовать существующие transformers из +`@/hooks` и `@/layouts/filters`. В page config не писать ручной повтор +универсальной логики. + +### 7.5. Relay pagination + +`buildOrdersQueryVariables(pageConfig)` передаёт: + +- `first`, `after` для движения вперёд; +- `last`, `before` для движения назад; +- `where`; +- mapped `orderBy`. + +`CursorPagination` получает реальные `pageInfo.startCursor`, +`pageInfo.endCursor`, `hasNextPage`, `hasPreviousPage` и `totalCount`. + +### 7.6. Page interactions + +- `Create order` открывает `{ mode: "create", onSaved: refetch }`; +- row click открывает `{ mode: "edit", entityId, onSaved: refetch }`; +- row action `Delete` использует общее подтверждение, передаёт `id` и + `expectedVersion`, а после успеха вызывает `refetch`; +- после сохранения модалка вызывает `onSaved`, затем закрывается; +- при изменении фильтров/поиска/сортировки selection и cursor сбрасываются; +- loading не очищает уже отображённые rows без необходимости; +- transport error отображается `Alert`, но не маскируется пустым списком. + +## 8. Основная Order modal + +### 8.1. Одна модалка для create/edit + +Старый `Edit.tsx` переносится в `OrderModal` с payload: + +```ts +export interface OrderModalPayload extends IModalStackPayload { + mode: "create" | "edit"; + entityId?: string; + onSaved?: () => Promise | unknown; +} +``` + +`useOrderModal` создаётся через `createModalStackHook("order")`. + +Модалка: + +- в create mode использует defaults и `useCreateOrder`; +- в edit mode загружает `useOrder(entityId)`; +- использует `FormProvider` и одну Zod schema; +- отслеживает `isDirty` через `setDirty`; +- использует `ModalLayout`/`ModalHeader`, а не локальный `antd Modal`; +- после save вызывает `onSaved`, сбрасывает dirty и делает `forcePop`; +- при not found и transport error показывает отдельные состояния; +- не хранит второй локальный `order` поверх результата hook без необходимости. + +### 8.2. Смысловые блоки формы + +Форма разделяется на блоки, заданные архитектурой Admin: + +1. **Связи** + - customer association; + - contact snapshot; + - shipping method; + - payment method; + - tags. +2. **Контент** + - order items; + - quantity, weight, cost price; + - fulfillment composition; + - payment summary. +3. **Дополнительные данные** + - shipping address; + - billing address; + - currency/display currency; + - external system id; + - tracking data. +4. **Активность** + - timeline; + - comment form; + - created/updated metadata; + - actor information. +5. **Жалобы и модерация** + - admin note; + - tags/flags; + - status controls и причины изменения в timeline. + +Старая order view не содержит отдельную complaint entity. Поэтому перенос не +должен придумывать mock-only complaints. Блок получает соответствующее orders +содержимое — admin note, flags/tags и историю операторских решений. Реальные +complaints добавляются позже только вместе с API-контрактом. + +### 8.3. Editability rules + +- draft order: доступны customer, items, shipping/payment details; +- active/completed/cancelled/archived: поля, которые старый UI делал read-only, + остаются read-only; +- status changes выполняются отдельными confirmation modals; +- inline quantity/weight/cost editors могут остаться `Popover`, но mutation + проходит через domain hook и учитывает `expectedVersion`; +- split/undo fulfillment и shipping actions не изменяют объект формы напрямую, + а выполняют mutation и refetch order. + +## 9. Все order-модалки + +Все полноэкранные и подтверждающие диалоги регистрируются в modal stack. Они не +должны управляться локальными `open` booleans внутри `OrderModal`. + +### 9.1. `order-status-modal` + +Переносит `OrderStatusModal.tsx` и общий `StatusModal.tsx`. + +Payload: + +```ts +{ + entityId: string; + expectedVersion: number; + nextStatus: OrderStatus; + onSaved?: () => Promise | unknown; +} +``` + +Форма: `comment`. Zod ограничивает длину и может требовать comment для +`CANCELLED`/`ARCHIVED`. Danger button используется для destructive transitions. + +### 9.2. `payment-status-modal` + +Переносит `PaymentStatusModal.tsx`. + +Payload содержит `orderId`, `paymentItemId`, `expectedVersion`, `nextStatus` и +`onSaved`. Форма содержит comment. Модалка показывает текущий и новый статус и +не разрешает submit в тот же статус. + +### 9.3. `fulfillment-status-modal` + +Переносит `FulfillmentStatusModal.tsx`. + +Payload содержит `orderId`, `fulfillmentId`, `expectedVersion`, `nextStatus` и +`onSaved`. Mock repository проверяет допустимые переходы и возвращает business +error при недопустимом переходе. + +### 9.4. `shipping-item-modal` + +Объединяет create/edit behavior старых `CreateShippingModal.tsx` и +`ShippingModal.tsx`. + +Payload: + +```ts +{ + mode: "create" | "edit"; + orderId: string; + fulfillmentId: string; + shippingItemId?: string; + expectedVersion: number; + onSaved?: () => Promise | unknown; +} +``` + +Поля: + +- shipping method; +- tracking code; +- отображаемый список включённых order items; +- при наличии в сохранённой view — tracking URL/notifications/estimated date. + +### 9.5. `shipping-details-modal` + +Переносит `ShippingDetailsModal.tsx`: + +- shipping method; +- shipping address; +- edit только для draft order; +- create/edit address поддерживается одной schema; +- `expectedVersion` обязателен. + +### 9.6. `payment-details-modal` + +Переносит `PaymentDetailsModal.tsx`: + +- payment method; +- billing address; +- edit только для draft order; +- `expectedVersion` обязателен. + +### 9.7. Вложенные picker-модалки + +Если customer, product, shipping method или payment method выбираются через +picker, picker открывается следующим уровнем modal stack. Order-specific modal +получает результат через typed payload callback или существующий picker API. +Нельзя возвращаться к старому drawer store. + +### 9.8. Modal registration + +`modals.ts` объявляет typed payloads через module augmentation и экспортирует +hooks. Компоненты лениво регистрируются в общей карте +`admin/src/domains/modals.tsx` по текущему паттерну приложения. + +Рекомендуемые type keys: + +- `order`; +- `order-status`; +- `order-payment-status`; +- `order-fulfillment-status`; +- `order-shipping-item`; +- `order-shipping-details`; +- `order-payment-details`. + +## 10. Forms, Zod и mappers + +### 10.1. Form state + +UI-local `OrderFormValues` допустим, потому что это editable draft state, а не +output view model. Он выводится из Zod schema: + +```ts +export type OrderFormValues = z.infer; +``` + +Нельзя использовать `ApiOrder` как mutable form object и нельзя отправлять +`getValues()` целиком как mutation input. + +### 10.2. Validation + +Минимальные правила: + +- quantity — integer `>= 1`; +- price/cost/weight — finite non-negative values; +- currency code — обязательный поддерживаемый code; +- email/phone — валидируются, если заполнены; +- address требует обязательные address fields, если заполнено хотя бы одно; +- shipping method/address consistency; +- payment method/billing address consistency; +- tracking code length; +- admin note/comment max length; +- draft должен содержать хотя бы одну позицию перед подтверждением; +- fulfillment quantities не превышают unfulfilled quantity. + +### 10.3. Input mappers + +`order-form.mapper.ts` содержит: + +- `buildOrderCreateInput(values)`; +- `buildOrderUpdateInput(values, order)`; +- `mapOrderToFormValues(order)` для заполнения editable form state. + +Первый и второй mapper: + +- trim строк; +- преобразуют пустые optional поля в `null`; +- отправляют ids вместо объектов select; +- не отправляют display-only поля; +- в update включают `expectedVersion: order.version`; +- добавляют `clientMutationId` для create, если это предусмотрено контрактом. + +`mapOrderToFormValues` допустим: это API output -> локальное состояние формы, +но не новый output view model для компонентов. + +### 10.4. API errors + +`order-errors.mapper.ts` содержит единую карту API field path -> React Hook Form +field path. + +Примеры: + +```text +customer.email -> customerDetails.email +shipping.address.city -> shippingAddress.city +payment.billingAddress -> billingAddress +items.0.quantity -> items.0.quantity +adminNote -> adminNote +``` + +Field errors устанавливаются через `setError`. Ошибки без известного field +показываются в global `Alert`. Transport error хранится отдельно и не +превращается в fake validation error. + +`VERSION_CONFLICT` не привязывается к полю. UI показывает понятное сообщение: +заказ изменён другим оператором, предлагает обновить данные и не закрывает +модалку автоматически. + +## 11. Optimistic concurrency + +`ApiOrder.version` обязателен в list/detail contract и во всех изменяющих +операциях. + +Правила: + +- detail query возвращает актуальную version; +- mapper берёт version только из загруженного order, не из form input; +- mock repository сравнивает `expectedVersion` атомарно перед mutation; +- успешная mutation увеличивает version; +- mutation payload возвращает обновлённый order; +- после nested mutation родительский `OrderModal` refetch-ит order, чтобы + получить новую version; +- повторный submit со старой version возвращает `VERSION_CONFLICT`; +- UI не делает автоматический retry mutation, который может перезаписать чужие + изменения. + +## 12. Hooks + +### 12.1. List hook + +```ts +interface UseOrdersReturn { + orders: ApiOrder[]; + connection: OrderConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} +``` + +Hook защищается от race conditions через request id или abort semantics, чтобы +медленный старый запрос не перезаписал новый результат после смены filters. + +### 12.2. Detail hook + +`useOrder(id)` возвращает `order`, `loading`, `error`, `refetch`. Он не вызывает +output mapper и не импортирует mocks. + +### 12.3. Mutation hooks + +Каждый user-facing use case получает отдельный hook. Hook: + +- нормализует unexpected errors; +- возвращает `userErrors` из payload; +- предоставляет `loading`, `error`, `reset`; +- не показывает notification самостоятельно, если текст зависит от UI context; +- не закрывает modal; +- возвращает обновлённый `ApiOrder` при успехе. + +После успешного сохранения source of truth обновляется через `refetch` на первом +этапе. Cache-specific оптимизация откладывается до реального Apollo transport. + +## 13. Адаптация UI и styles + +### 13.1. Что переиспользуется + +Ant Design компоненты из старого UI в основном совместимы: + +- `Avatar`, `Button`, `Divider`, `Dropdown`, `Input`, `Popover`, `Select`, + `Skeleton`, `Tag`, `Typography`; +- `react-hook-form` и `Controller`; +- иконки заменяются на Lucide из `react-icons/lu`, если в новом Admin уже есть + эквивалент. + +Полноэкранные модалки используют новый `ModalLayout`; небольшие confirm UI +также регистрируются в stack и не должны вкладывать собственный visible-state +`antd Modal` поверх stack item. + +### 13.2. Emotion -> `antd-style` + +Все импорты: + +```ts +import { css } from "@emotion/react"; +``` + +заменяются на: + +```ts +import { createStyles } from "antd-style"; +``` + +Стили используют design tokens: + +| Старый token/style | Новый источник | +| --- | --- | +| `var(--x2)`, `var(--x4)` | `token.paddingSM`, `token.padding` | +| `var(--color-border)` | `token.colorBorder` | +| `var(--color-gray-4)` | `token.colorBorderSecondary` | +| `var(--radius-base)` | `token.borderRadius` | +| hardcoded background | `token.colorBgContainer` / `colorFillAlter` | +| hardcoded text color | `token.colorText` / `colorTextSecondary` | + +Правила: + +- не переносить Emotion `css` props; +- не добавлять CSS modules только для orders; +- не переносить старые custom `Box`/`Flex`, если эквивалент даёт `antd Flex`; +- layout grids описывать через `createStyles`; +- inline style оставлять только для динамических значений или небольших + AG Grid cell layouts; +- responsive rules хранить рядом с modal/page styles; +- использовать токены, чтобы dark theme работала автоматически. + +### 13.3. Общие компоненты + +Перед копированием `AddressForm`, method selectors, money formatter, validation +alert и product/customer pickers нужно проверить аналоги нового Admin. Если +аналог уже есть, Orders использует его. Order-specific копия допускается только +при отличающемся контракте и должна оставаться внутри domain module. + +## 14. Порядок реализации + +### Этап 1. Каркас и контракт + +- создать структуру `all-orders/{api,graphql,hooks,mappers,components,modals}`; +- описать временные operation types и enums; +- создать focused GraphQL documents как спецификацию будущего API; +- создать typed modal payloads и keys; +- не переносить UI до фиксации формы `ApiOrder`. + +### Этап 2. Mock repository + +- создать representative seed; +- реализовать filter/search/sort; +- реализовать Relay pagination; +- реализовать create/update/status/item/shipping actions; +- реализовать version conflict и field user errors; +- обернуть repository в request layer. + +### Этап 3. Таблица + +- заменить `EmptySectionPage`; +- добавить `usePageConfig`, filter schema и page config; +- добавить AG Grid columns/cell renderers; +- подключить `CursorPagination`; +- открыть create/edit order modal; +- активировать только `all-orders-list` после завершения UI. + +### Этап 4. Основная модалка + +- перенести композицию `Edit.tsx`; +- добавить Zod schema и form mapper; +- разделить UI на смысловые sections; +- перенести draft/active fulfillment и payment summary; +- перенести customer/contact, details, tags, note и timeline; +- подключить create/update mock mutations. + +### Этап 5. Вложенные модалки + +- status confirmation base UI; +- order status; +- отдельное cancel action, если будущий контракт сохраняет старую семантику + `cancel`, отличную от обычного status update; +- payment status; +- fulfillment status; +- shipping item create/edit; +- shipping details; +- payment details; +- nested picker integrations. + +### Этап 6. Остальные действия + +- add/update/delete order item; +- delete order и cancel order; +- quantity/weight/cost inline editors; +- customer attach/detach; +- tags/admin note/comment; +- split/undo fulfillment; +- shipping tracking update; +- автоматический refetch и version refresh после каждого действия. + +### Этап 7. Styles и cleanup + +- заменить все Emotion styles на `antd-style`; +- убрать зависимости от старых `Box`, `Flex`, drawer store, entity classes и + старого i18n API; +- проверить light/dark theme и responsive layout; +- убедиться, что `OrderDetails/Old.tsx` не был перенесён; +- удалить временные compatibility imports внутри нового модуля. + +## 15. Acceptance criteria + +### Страница + +- `/orders` открывает реальную таблицу, а не placeholder; +- отображается не менее двух Relay pages mock-данных; +- next/previous работают через cursors; +- поиск, каждый required filter и server sort меняют результат mock request; +- row click открывает edit modal; +- create открывает ту же modal в create mode; +- список обновляется после create/edit/nested mutation. + +### Основная модалка + +- старая order view визуально и функционально сохранена; +- draft/active UI отличается по тем же business rules; +- все смысловые sections доступны; +- dirty close защищён modal stack; +- Zod ошибки отображаются рядом с полями; +- API `userErrors` привязываются к полям; +- global и transport errors показываются отдельно; +- successful save закрывает modal только после `onSaved`. + +### Вложенные модалки + +- все семь order modal types зарегистрированы; +- status, payment, fulfillment и shipping flows работают через stack; +- родительская order modal остаётся под дочерней; +- после nested save родитель получает свежий order/version; +- destructive actions визуально отмечены и требуют подтверждения/comment по + schema. + +### Архитектура + +- UI не импортирует seed/mock repository; +- hooks не импортируют старые entity classes; +- query output не преобразуется в новую output view model; +- form/input/error mappers разделены; +- `version` передаётся во всех update mutations; +- styles написаны через `antd-style` и tokens; +- список использует `usePageConfig`, AG Grid и Relay connection; +- GraphQL transport можно подключить без изменения UI component interfaces. + +## 16. Не входит в перенос + +- адаптация UI к текущей реальной orders service schema; +- изменение старой семантики order view; +- новый backend resolver специально под UI; +- CRM/Kanban board из старого `modules/crm`; +- Draft Orders как отдельная страница; +- Abandoned Checkouts; +- refunds/exchanges/returns как отдельные domains; +- перенос `OrderDetails/Old.tsx`; +- редизайн бизнес-переходов статусов; +- переход денежных полей на другую единицу без согласованного API change. + +## 17. Основные риски + +### Расхождение временного и реального API + +Сохранённая view богаче текущего orders API. Риск ограничивается явной границей +контракта: UI зависит от `ApiOrder`, а mock transport изолирован. После появления +API расхождения должны решаться в schema/operation contract или отдельной UI +миграции, но не скрытым permanent adapter. + +### Потеря поведения при визуальном копировании + +Orders содержит много небольших mutations. Простое копирование `Edit.tsx` без +инвентаризации действий потеряет split fulfillment, tracking, comment, tags, +quantity, weight или cost price. Поэтому перенос выполняется по use-case hooks и +реестру компонентов, а не одним большим компонентом. + +### Несогласованная version после вложенной mutation + +Любая nested mutation меняет order version. Если родитель не refetch-ит order, +следующее сохранение даст ложный conflict. Каждый дочерний modal/action обязан +вызывать общий `onSaved/refetch` до закрытия. + +### Двойное состояние формы и repository + +Нельзя оптимистично мутировать `ApiOrder` внутри компонентов и одновременно +refetch-ить repository. На mock-этапе выбран простой и предсказуемый путь: +mutation payload -> successful result -> refetch -> reset form при необходимости. + +## 18. Итоговая точка подключения реального API + +После реализации этого плана Orders UI будет завершён на mock transport, но его +публичная граница уже будет GraphQL-shaped. Для подключения backend останется: + +- синхронизировать `ApiOrder`, inputs, enums и connections с generated schema; +- подключить Apollo внутри hooks; +- удалить `api/order-mock-repository.ts` и seed; +- сохранить page config, forms, mappers, modal payloads и UI components. + +Это отделяет большой визуальный перенос от последующей миграции данных и не +блокирует разработку интерфейса отсутствующим или несовпадающим API. diff --git a/admin/docs/pricing-widget-api-plan.md b/admin/docs/pricing-widget-api-plan.md index 7dcb19a34..3b690e0e2 100644 --- a/admin/docs/pricing-widget-api-plan.md +++ b/admin/docs/pricing-widget-api-plan.md @@ -114,7 +114,7 @@ interface PriceHistoryStatistics { } interface GetPriceStatisticsInput { - projectId: string; + storeId: string; variantId: string; currency: CurrencyCode; from: Date; @@ -131,7 +131,7 @@ async getPriceStatistics(input: GetPriceStatisticsInput): Promise { async currentPrice() { const services = this.$ctx.kernel.getServices(); const price = await services.repository.pricing.getCurrentPrice({ - projectId: this.$ctx.projectId, + storeId: this.$ctx.storeId, variantId: this.$props.variantId, currency: this.$props.currency, }); @@ -196,7 +196,7 @@ export class PricingWidgetResolver extends InventoryType { async currentCostPrice() { const services = this.$ctx.kernel.getServices(); const cost = await services.repository.cost.getCurrentCost({ - projectId: this.$ctx.projectId, + storeId: this.$ctx.storeId, variantId: this.$props.variantId, currency: this.$props.currency, }); @@ -210,7 +210,7 @@ export class PricingWidgetResolver extends InventoryType { const first = this.$props.first ?? 50; const prices = await services.repository.pricing.getPriceHistory({ - projectId: this.$ctx.projectId, + storeId: this.$ctx.storeId, variantId: this.$props.variantId, currency: this.$props.currency, from, @@ -244,7 +244,7 @@ export class PricingWidgetResolver extends InventoryType { const { from, to } = this.getDateRange(); const stats = await services.repository.pricing.getPriceStatistics({ - projectId: this.$ctx.projectId, + storeId: this.$ctx.storeId, variantId: this.$props.variantId, currency: this.$props.currency, from, diff --git a/admin/docs/product-attributes-api-integration-plan.md b/admin/docs/product-attributes-api-integration-plan.md new file mode 100644 index 000000000..1c82d3bbe --- /dev/null +++ b/admin/docs/product-attributes-api-integration-plan.md @@ -0,0 +1,802 @@ +# Product Attributes API Integration Plan + +## Goal + +Replace the Admin product attributes mock flow with the current Admin GraphQL API. + +Product attributes are represented by `ProductFeature` in the Admin API: + +- `isGroup: true` means a root-level attribute group. +- `isGroup: false` means an attribute. +- `index: [Int!]!` is the tree position. Root rows use `[0]`, `[1]`; child attributes use `[0, 0]`, `[0, 1]`. +- `values` belongs only to attributes. Groups return an empty `values` array and must not send values in sync input. + +The Admin product details UI must read attributes from `ApiProduct.features`. The edit modal must save the complete feature tree through `catalogMutation.productFeaturesSync`. + +## Baseline + +### Backend API + +The catalog Admin GraphQL schema already exposes product feature operations: + +- `productFeatureCreate` +- `productFeatureUpdate` +- `productFeatureDelete` +- `productFeaturesSync` + +Use `productFeaturesSync` for the first Admin attributes editor integration. + +Reasons: + +- the current editor works with a complete tree snapshot; +- the resolver decodes global product, feature, and feature value IDs before calling the script; +- the script applies a complete replacement transaction; +- features omitted from `input.features` are deleted; +- values omitted from an attribute `values` list are deleted; +- the active API e2e contract covers the sync path. + +Do not use the point mutations for this integration. They remain out of scope. + +### Backend Validation Contract + +The UI must assume the server is the source of truth and must surface returned `userErrors`. + +Client validation should catch the same obvious errors before sync: + +- `productId` must be present. +- feature and value names must be non-empty after trimming. +- generated feature and value slugs must satisfy `/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/`. +- feature indexes must contain one or two non-negative integer segments. +- groups must be root rows only. +- groups must not send values. +- child rows must have an existing parent group. +- attributes may be root rows or direct children of a group only. +- feature indexes and sibling positions must be unique. +- feature slugs must be unique within the product. +- value indexes and value slugs must be unique within each attribute. +- feature IDs must be unique. +- value IDs must be unique. +- existing feature IDs must belong to the product. +- existing value IDs must belong to the same feature. +- a new feature must not send existing value IDs. +- an existing feature must not change type between group and attribute. + +The client cannot fully validate database ownership, so it must still keep the modal open and show server `userErrors`. + +### Frontend State + +The product module already has generated API types in `admin/src/graphql/types.ts`, including: + +- `ApiProductFeature` +- `ApiProductFeatureValue` +- `ApiProductFeatureSyncItemInput` +- `ApiProductFeatureValueSyncInput` +- `ApiProductFeaturesSyncInput` +- `ApiProductFeaturesSyncPayload` + +`ProductFeatureFields` already exists and `ProductDetailsFields` already selects `features`. + +Current gaps: + +- `ProductDetailsCard` still renders attributes from `supplementalData.attributes`. +- `ProductDetailsSupplementalData` still contains product attributes mock data. +- `AttributesSection` accepts `IAttributeRow[]` from the edit modal type boundary. +- `EditAttributesModal` initializes rows with `createMockData()`. +- `IEditAttributesModalPayload` only carries `productId`. +- save only shows `Product attribute updates are not API-backed yet`. +- the values column only displays a comma-separated string and does not map edits back to feature values. +- there is no active Admin UI e2e coverage for the attributes modal. + +### Existing E2E Coverage + +Keep `e2e/tests/inventory-api/features-sync.spec.ts` as the API contract test. + +It covers: + +- creating standalone attributes; +- creating grouped attributes; +- updating feature and value names; +- adding values; +- reordering features; +- deleting features by omission; +- deleting all features with an empty list; +- deleting values by omission; +- invalid product and invalid group values errors; +- full structure replacement; +- complex reorder and group moves; +- preserving feature and value IDs on update. + +Add Admin UI e2e coverage only after the modal is API-backed. + +## Integration Rules + +Follow `knowledge/vault/patterns/admin-graphql-layer.md`. + +Required rules for this work: + +- Import generated API types directly from `@/graphql/types` at the usage site. +- Do not re-export generated API types from module barrels, component barrels, `graphql/index.ts`, `operation-types.ts`, or feature-local `types.ts`. +- Do not create API output view models for `ProductFeature`. +- API-backed display components must accept generated API data directly. +- `AttributesSection` must accept `features: ApiProductFeature[]`, not editor rows. +- UI-local editor rows are allowed only inside the edit modal boundary. +- Mappers may convert `ApiProductFeature[]` to modal-local editor rows and editor rows back to `ApiProductFeaturesSyncInput`. +- Mappers must not perform GraphQL calls. +- Hooks own Apollo calls and normalize returned `userErrors`. +- Components must not inspect raw payload paths like `data.catalogMutation.productFeaturesSync`. +- The mutation freshness strategy must be explicit. For this integration, use the modal payload `onSaved` callback to refetch product details. +- Do not use mocks in new API-backed hooks. + +## Target File Layout + +Add or update files under the existing product module: + +```text +admin/src/domains/inventory/products/ + graphql/ + fragments.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-sync-product-features.ts + index.ts + mappers/ + product-features.mapper.ts + product-feature-errors.mapper.ts + index.ts + components/ + attributes-section.tsx + modals/ + edit-attributes-modal/ + edit-attributes-modal.tsx + types.ts + components/ +``` + +Rename `IAttributeRow` to `AttributeEditorRow` unless the implementation cost is higher than the benefit. Whether renamed or not, the type must remain modal-local and must not be used as the API-backed display prop contract. + +## GraphQL Changes + +### Product Feature Fragment + +Keep a focused `ProductFeatureFields` fragment in `products/graphql/fragments.ts`. + +Required fields: + +```graphql +fragment ProductFeatureFields on ProductFeature { + id + name + slug + isGroup + index + values { + id + name + slug + index + } +} +``` + +Do not add `parent` or `children` for this first pass. Display and editor state derive hierarchy from `index`. + +`slug` is still required by the current schema and sync input. If the backend removes feature and value slugs before this UI work lands, regenerate types and update this fragment, mapper, validation, and e2e assertions in the same implementation. + +### Sync Mutation + +Add `PRODUCT_FEATURES_SYNC_MUTATION` to `products/graphql/mutations.ts`. + +Also import `PRODUCT_FEATURE_FRAGMENT` from `./fragments`. + +```graphql +mutation ProductFeaturesSync($input: ProductFeaturesSyncInput!) { + catalogMutation { + productFeaturesSync(input: $input) { + product { + id + features { + ...ProductFeatureFields + } + } + features { + ...ProductFeatureFields + } + userErrors { + ...UserErrorFields + } + } + } +} +``` + +Append `${PRODUCT_FEATURE_FRAGMENT}` and `${USER_ERROR_FRAGMENT}` to the document. + +Select only the fields needed to refresh the attributes area. Do not request the full product details payload. + +### Operation Types + +Update `products/graphql/operation-types.ts` with local operation response and variables types built from generated schema types. + +Do not re-export generated schema types. + +Use a partial product type because the sync mutation selects only `id` and `features`: + +```ts +import type { + ApiCatalogMutation, + ApiGenericUserError, + ApiProduct, + ApiProductFeature, + ApiProductFeaturesSyncInput, +} from "@/graphql/types"; + +export type ProductFeaturesSyncProduct = Pick & { + features: ApiProductFeature[]; +}; + +export interface ProductFeaturesSyncPayloadData { + product: ProductFeaturesSyncProduct | null; + features: ApiProductFeature[]; + userErrors: ApiGenericUserError[]; +} + +export interface ProductFeaturesSyncMutationData { + catalogMutation: Pick & { + productFeaturesSync: ProductFeaturesSyncPayloadData; + }; +} + +export interface ProductFeaturesSyncMutationVariables { + input: ApiProductFeaturesSyncInput; +} +``` + +Export these local operation types from `products/graphql/index.ts`. This is allowed because they are operation-local types, not generated API type re-exports. + +## Editor State And Mapper Plan + +Create `product-features.mapper.ts`. + +### Editor Types + +Keep editor row types in `modals/edit-attributes-modal/types.ts`. + +Recommended shape: + +```ts +export type AttributeEditorRowType = "group" | "attribute"; + +export interface AttributeEditorValue { + id: string; + apiId?: string; + name: string; + slug: string; + sortIndex: number; +} + +export interface AttributeEditorRow extends ITreeTableRow { + id: string; + apiId?: string; + apiType?: AttributeEditorRowType; + type: AttributeEditorRowType; + name: string; + slug: string; + parentId: string | null; + sortIndex: number; + level: 0 | 1; + values: AttributeEditorValue[]; +} +``` + +Rules: + +- `id` is the UI row identity. +- `apiId` is the persisted global API ID. +- Existing API rows use `id = apiId = feature.id`. +- New rows use temporary IDs such as `tmp-feature-${crypto.randomUUID()}` and no `apiId`. +- Existing values use `id = apiId = value.id`. +- New values use temporary IDs such as `tmp-value-${crypto.randomUUID()}` and no `apiId`. +- Sync input sends only `apiId`, never temporary IDs. +- `apiType` is set for persisted rows and used to detect accidental group/attribute type changes before save. + +### API To Editor Rows + +Function: + +```ts +apiProductFeaturesToAttributeEditorRows( + features: ApiProductFeature[], +): AttributeEditorRow[] +``` + +Rules: + +- Treat API features as the server-valid source of truth. +- Sort features lexicographically by `index`. +- Convert `isGroup: true` to `type: "group"`. +- Convert `isGroup: false` to `type: "attribute"`. +- Root features have `index.length === 1`, `parentId: null`, `level: 0`, and `sortIndex: index[0]`. +- Child attributes have `index.length === 2`, `level: 1`, and `sortIndex: index[1]`. +- Derive child `parentId` by matching `index.slice(0, -1)` to a group row. +- If an unexpected child has no matching parent group, keep the row out of the editable save set and expose a modal-level error. Do not silently remap it to root because the next save would change persisted structure. +- Groups always get `values: []`. +- Attribute values are sorted by `value.index`. +- Values preserve `apiId`. + +### Value Text Editing + +Provide an explicit parser for comma-separated values. + +Function: + +```ts +parseAttributeValuesText(input: { + text: string; + existingValues: AttributeEditorValue[]; +}): AttributeEditorValue[] +``` + +Rules: + +- Split by comma. +- Trim each segment. +- Drop empty segments created by leading, trailing, or repeated commas. +- Preserve existing value IDs by exact normalized name match first. +- For unmatched edited values, preserve the value at the same previous order index to support renames. +- Create temporary IDs for new values. +- Regenerate `sortIndex` from the parsed order. +- Regenerate slugs from value names and de-duplicate slugs within the attribute. + +### Editor Rows To Sync Input + +Prefer a builder that returns both the API input and field path metadata for error mapping. + +Function: + +```ts +interface ProductFeaturesSyncDraft { + input: ApiProductFeaturesSyncInput; + featureRowsByInputIndex: AttributeEditorRow[]; + valuesByInputPath: Record; +} + +buildProductFeaturesSyncDraft(input: { + productId: string; + rows: AttributeEditorRow[]; +}): ProductFeaturesSyncDraft +``` + +Rules: + +- Build a complete snapshot for all rows that are valid editor rows. +- Root rows are sorted by `sortIndex` and receive indexes `[0]`, `[1]`, `[2]`. +- Child attributes are sorted by `sortIndex` within their parent group and receive indexes like `[0, 0]`, `[0, 1]`. +- Groups must be root-only. +- Attributes can be root-level or direct children of a group. +- Groups send no `values`. +- Attributes send sorted `values`. +- Send feature `id` only when `row.apiId` is present. +- Send value `id` only when `value.apiId` is present. +- Generate feature slugs from row names while schema requires slugs. +- Generate value slugs from value names while schema requires slugs. +- Feature slugs must be unique across the product. De-duplicate generated slugs with numeric suffixes. +- Value slugs must be unique within the attribute. De-duplicate generated slugs with numeric suffixes. +- The slug generator must never return an empty string. Use a deterministic fallback such as `feature-${position + 1}` or `value-${position + 1}`. +- Preserve existing feature and value IDs on update. +- New attributes, groups, and values are created by omitting IDs. +- Deleted rows and values are omitted from the complete snapshot. + +Also export a convenience wrapper only if needed: + +```ts +attributeEditorRowsToProductFeaturesSyncInput(input: { + productId: string; + rows: AttributeEditorRow[]; +}): ApiProductFeaturesSyncInput +``` + +### Slug Generation + +Use the project's existing `slugify` approach from product create as the base implementation. + +Required helper behavior: + +```ts +toFeatureSlug(name: string, fallback: string): string +toUniqueSlug(slug: string, used: Set): string +``` + +Rules: + +- transliterate names; +- lowercase; +- replace invalid separators with `-`; +- collapse repeated hyphens; +- trim leading and trailing hyphens; +- use fallback when the result is empty; +- ensure the final value matches the backend regex; +- suffix duplicates as `slug-2`, `slug-3`, etc. + +### Client Validation + +Function: + +```ts +validateAttributeEditorRows(input: { + productId: string; + rows: AttributeEditorRow[]; +}): ApiGenericUserError[] +``` + +Validation must run before calling `productFeaturesSync`. + +It must detect: + +- missing product ID; +- empty feature name; +- empty value name; +- invalid generated feature slug; +- invalid generated value slug; +- duplicate feature row IDs; +- duplicate feature API IDs; +- duplicate value IDs; +- duplicate root positions after drag; +- duplicate child positions after drag; +- duplicate feature slugs; +- duplicate value slugs within an attribute; +- group nested under another row; +- group with values; +- attribute nested deeper than one group; +- attribute parent missing; +- attribute parent not a group; +- persisted row type changed from its `apiType`; +- new feature containing a persisted value API ID. + +Validation errors must use these server-like field paths: + +- `["productId"]` +- `["features", featureIndex, "name"]` +- `["features", featureIndex, "index"]` +- `["features", featureIndex, "values", valueIndex, "name"]` + +Server `userErrors` remain authoritative and must still be displayed. + +## Hook Plan + +Create `use-sync-product-features.ts`. + +Target contract: + +```ts +interface SyncProductFeaturesResult { + product: ProductFeaturesSyncProduct | null; + features: ApiProductFeature[]; + userErrors: ApiGenericUserError[]; +} + +interface UseSyncProductFeaturesReturn { + syncProductFeatures: ( + input: ApiProductFeaturesSyncInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} +``` + +Implementation: + +- use `useMutation(PRODUCT_FEATURES_SYNC_MUTATION)`; +- unwrap `payload.product`, `payload.features`, and `payload.userErrors`; +- return `product: null`, `features: []`, and a normalized `UNEXPECTED_ERROR` user error for unexpected exceptions; +- expose Apollo `loading`, `error`, and `reset`; +- do not expose raw nested payload paths to components; +- do not import mocks; +- do not perform a broad cache write in the first implementation; +- document that freshness is handled by the modal payload `onSaved` callback. + +The hook may optionally accept future options for `refetchQueries` or cache updates, but the first integration should keep refresh behavior explicit through `onSaved`. + +## UI Integration Plan + +### Product Details Read Path + +Change `ProductDetailsCard` so attributes render from `product.features`, not `supplementalData.attributes`. + +Required changes: + +- update `AttributesSection` props to `features: ApiProductFeature[]`; +- pass `product.features` to `AttributesSection`; +- remove `supplementalData.attributes` from `ProductDetailsCard`; +- remove `attributes` from `ProductDetailsSupplementalData` and `productDetailsMockData` if no remaining product-details consumer needs it; +- keep `supplementalData` only for remaining mock-only islands such as reviews and bundles. + +`AttributesSection` must not import modal editor row types. + +Target props: + +```ts +interface AttributesSectionProps { + features: ApiProductFeature[]; + actions?: ReactNode; +} +``` + +Display rules: + +- derive root attributes and groups internally from `features`; +- sort features by `index`; +- sort values by `value.index`; +- show attribute value names as a comma-separated string; +- show `--` when an attribute has no values; +- hide empty group bodies if they have no child attributes; +- keep the section visible when `features.length === 0` if `actions` exists, so users can create the first attribute; +- render an empty state for no attributes instead of returning `null` when an edit action is available. + +### Modal Payload + +Extend `IEditAttributesModalPayload`: + +```ts +interface IEditAttributesModalPayload extends IModalStackPayload { + productId: string; + features: ApiProductFeature[]; + onSaved?: () => Promise | unknown; +} +``` + +Import `ApiProductFeature` directly from `@/graphql/types`. + +Update `useProductModals` to accept refresh callbacks: + +```ts +interface UseProductModalsOptions { + onProductRefresh?: () => Promise; +} + +export const useProductModals = ( + product: ApiProduct, + options: UseProductModalsOptions = {}, +) => { + // ... +}; +``` + +Then pass the callback from `ProductDetailsCard`: + +```ts +const modals = useProductModals(product, { onProductRefresh }); +``` + +`handleEditAttributes` must open the modal with: + +- `productId: product.id`; +- `features: product.features`; +- `onSaved: options.onProductRefresh`. + +If changing `useProductModals` creates unnecessary churn, `ProductDetailsCard` may open only the attributes modal directly. Do not omit the refresh callback. + +### Edit Attributes Modal + +Update `EditAttributesModal`: + +- read typed payload from modal stack context; +- require `productId`; +- initialize rows from `payload.features` through `apiProductFeaturesToAttributeEditorRows`; +- remove `createMockData()` import; +- keep row edits in modal-local state; +- keep `useTreeTableDragDrop` if it remains compatible with the target tree shape; +- wire the values column with `valueGetter` and `valueSetter` or a dedicated edit handler; +- parse comma-separated value edits with `parseAttributeValuesText`; +- preserve value IDs according to the parser rules; +- create temporary IDs for new rows and values; +- run `validateAttributeEditorRows` before sync; +- build the sync draft with `buildProductFeaturesSyncDraft`; +- call `useSyncProductFeatures().syncProductFeatures(draft.input)`; +- disable submit while saving; +- show client validation errors in the modal; +- show API `userErrors` in the modal; +- keep the modal open on validation errors or API errors; +- do not clear dirty state on failed save; +- after successful save, await `onSaved?.()`; +- after refresh completes, call `setDirty(false)` and close the modal; +- show a success notification only after the mutation succeeds and there are no `userErrors`. + +Submit flow: + +```ts +const handleSave = async () => { + const validationErrors = validateAttributeEditorRows({ productId, rows: allRows }); + if (validationErrors.length > 0) { + setUserErrors(validationErrors); + return; + } + + const draft = buildProductFeaturesSyncDraft({ productId, rows: allRows }); + const result = await syncProductFeatures(draft.input); + + if (result.userErrors.length > 0) { + setUserErrors(result.userErrors); + return; + } + + await payload.onSaved?.(); + setDirty(false); + pop(); +}; +``` + +### Drag And Tree Constraints + +Keep using `useTreeTableDragDrop` only if it preserves the required structure. + +Required behavior: + +- groups can only be root rows; +- attributes can be root rows or direct children of a group; +- no nesting deeper than one group; +- deleting a group deletes its child attributes from modal state; +- sort indexes are normalized after each drag; +- final sync payload is built from normalized rows, not from raw visual order; +- invalid drag outcomes are blocked or reverted before save. + +Even if drag guards are present, save must still run `validateAttributeEditorRows`. + +## Error Handling Plan + +Create `product-feature-errors.mapper.ts`. + +Minimum required exports: + +```ts +formatProductFeatureUserError(error: ApiGenericUserError): string +formatProductFeatureUserErrors(errors: ApiGenericUserError[]): string[] +``` + +Optional field-level exports: + +```ts +mapProductFeatureUserErrorsToEditorErrors(input: { + errors: ApiGenericUserError[]; + draft: ProductFeaturesSyncDraft; +}): ProductFeatureEditorErrorMap +``` + +Minimum first-pass behavior: + +- render all client validation and API `userErrors` in a modal-level `Alert`; +- include every error message, not only the first one; +- keep the modal open on errors; +- do not clear dirty state on errors. + +Field-level rendering can be added in the same implementation if it is cheap: + +- map paths like `["features", "0", "name"]` to `draft.featureRowsByInputIndex[0]`; +- map paths like `["features", "0", "values", "1", "name"]` through `draft.valuesByInputPath`; +- highlight affected grid cells. + +Do not inline repeated field path parsing inside React components. + +## E2E Plan + +Do not replace `e2e/tests/inventory-api/features-sync.spec.ts`. + +Add focused Admin UI coverage after the modal is API-backed: + +```text +e2e/tests/admin-ui/product-update-attributes.spec.ts +``` + +Add stable test IDs needed by the spec: + +- `product-attributes-section` +- `product-attributes-empty-state` +- `product-attributes-actions-button` +- `edit-attributes-modal` +- `edit-attributes-grid` +- `edit-attributes-add-button` +- `edit-attributes-add-attribute-item` +- `edit-attributes-add-group-item` +- `submit-edit-attributes-form-button` +- `edit-attributes-error-alert` + +Recommended scenarios: + +- create a product with no features, open details, assert the attributes empty state is visible, add the first standalone attribute, save, reload, assert it is shown; +- add a group and child attributes, save, reload, assert grouping and values; +- edit values through the comma-separated editor, save, reload, assert values persist in order; +- reorder root attributes and child attributes, save, reload, assert ordering; +- delete an attribute and delete a group, save, reload, assert removed items are gone; +- submit invalid rows and assert the modal shows errors without closing; +- edit an existing attribute and value, save, assert feature and value IDs are preserved through an API read. + +Per project instructions, e2e specs should be run one file at a time only when verification is explicitly needed. Do not run `test` or `tsc` for this planning task. + +## Implementation Phases + +### Phase 1: GraphQL Operation And Types + +- Confirm `ProductFeatureFields` has all required fields. +- Add `PRODUCT_FEATURES_SYNC_MUTATION`. +- Add local operation data, payload, product, and variables types. +- Export operation documents and operation-local types from existing product GraphQL barrels. +- Do not re-export generated API types. + +### Phase 2: Editor Types And Mappers + +- Rename or constrain `IAttributeRow` to a modal-local `AttributeEditorRow`. +- Add API-to-editor mapper. +- Add value text parser. +- Add sync draft builder. +- Add slug generation helpers. +- Add client validation helpers. +- Add error formatting and optional field path mapping. + +### Phase 3: Product Details Read Path + +- Change `AttributesSection` to accept `ApiProductFeature[]`. +- Derive display groups internally from API features. +- Keep the section visible with an empty state when edit actions exist. +- Render `ProductDetailsCard` attributes from `product.features`. +- Remove product attributes from `ProductDetailsSupplementalData` usage. +- Keep unrelated mock-only supplemental fields unchanged. + +### Phase 4: Modal API Integration + +- Extend attributes modal payload. +- Pass product features and refresh callback through `useProductModals`. +- Replace mock initialization with API feature rows. +- Implement add group and add attribute with temporary IDs. +- Implement comma-separated value editing. +- Implement validation, sync draft build, and `productFeaturesSync` save. +- Keep modal open on validation or API errors. +- Close only after successful sync and refresh callback completion. + +### Phase 5: Refresh Strategy + +- Use `onSaved` product details refetch for the first implementation. +- Do not write brittle manual cache updates for the paginated product details query in this pass. +- Consider a focused Apollo cache update later only after product details cache behavior is stable. + +### Phase 6: Admin UI E2E Coverage + +- Keep API e2e as the sync semantics contract. +- Add one focused Admin UI spec for product attribute editing. +- Run only that one spec when verification is explicitly requested. +- Do not broaden the test run. + +## Out Of Scope + +- Backend schema changes. +- Removing `slug` from feature contracts. +- Fixing or using `productFeatureCreate`, `productFeatureUpdate`, or `productFeatureDelete`. +- Replacing reviews, bundles, analytics, or other unrelated mock-only islands. +- Reworking the attributes modal design beyond what is required for API-backed editing. +- Rich value editing UI beyond the comma-separated first pass. +- Manual Apollo cache updates for product details pagination. + +## Acceptance Criteria + +- Product details attributes render from `product.features`. +- `AttributesSection` accepts generated API data directly and does not import modal editor row types. +- Product details still exposes an edit action when a product has no attributes. +- `ProductDetailsSupplementalData` is no longer used for product attributes. +- `EditAttributesModal` opens with the current product features from payload. +- `EditAttributesModal` no longer imports `createMockData`. +- New rows and values use temporary local IDs. +- Existing feature and value global IDs are stored separately as `apiId`. +- Save calls `catalogMutation.productFeaturesSync` with a complete snapshot. +- Existing feature and value IDs are preserved on update. +- New attributes, groups, and values are created by omitting IDs. +- Deleted rows and values are omitted from the snapshot and removed by the API. +- Groups send no values. +- Slugs are generated, valid, non-empty, and de-duplicated. +- Client validation blocks invalid structure before the mutation. +- API `userErrors` are visible and prevent closing the modal. +- Failed saves do not clear dirty state. +- Successful save awaits product refresh, clears dirty state, and closes the modal. +- No generated API types are re-exported through local barrels. +- No API output view model is introduced for product features. +- Admin UI e2e coverage is added for the API-backed attributes workflow. diff --git a/admin/docs/product-create-graphql-integration-plan.md b/admin/docs/product-create-graphql-integration-plan.md new file mode 100644 index 000000000..87ad031b2 --- /dev/null +++ b/admin/docs/product-create-graphql-integration-plan.md @@ -0,0 +1,307 @@ +# Plan: Product Create GraphQL Integration + +## Goal + +Integrate the Admin create product modal with the GraphQL `productCreate` API using the module architecture defined in `knowledge/vault/patterns/admin-graphql-layer.md`. + +The integration must create products through `inventoryMutation.productCreate`, upload/select media before submit, send enabled variant handles, handle API `userErrors`, and keep product list/detail screens ready for API-backed data. + +## Current State + +- A preliminary `PRODUCT_CREATE_MUTATION` exists in `src/domains/inventory/graphql/mutations.ts`. +- A preliminary `useCreateProduct` hook exists in `src/domains/inventory/products/hooks/use-create-product.ts`. +- A preliminary `prepareProductPayload` helper exists and maps modal data into `ApiProductCreateInput`. +- `CreateProductModal` currently calls `useCreateProduct`, but this is not the final integration shape defined by this plan. +- The current implementation still needs to be aligned with the Admin GraphQL layer rules: product-specific GraphQL files, operation types, mappers, normalized error mapping, and documented refresh behavior. +- Product list still uses `@/mocks/products/products-list`. +- Product details still depend on mock-heavy UI models with fields that are not present on `ApiProduct`. +- Category API is not present in the current generated Admin GraphQL types, so category assignment must stay out of product create integration until the API contract exists. + +## Target API Contract + +Use `ApiProductCreateInput`: + +```ts +{ + title: string; + handle: string; + description?: ApiDescriptionInput; + mediaFileIds?: string[]; + options?: Array<{ + name: string; + slug: string; + displayType?: string; + values: Array<{ name: string; slug: string }>; + }>; + variants?: Array<{ handle: string }>; +} +``` + +The create modal sends only the fields collected by the current UI: `title`, `handle`, `description`, `media`, `hasVariants`, `options`, and generated `variants` selection. The payload mapper converts those fields into `ApiProductCreateInput`. + +## UI Form To Payload Contract + +This contract describes the current create product UI only. It must not include fields from product details mocks or future product editing screens. + +### Form State Shape + +The modal owns this form state: + +```ts +interface CreateProductFormValues { + title: string; + handle: string; + description: OutputData | null; + media: ApiFile[]; + hasVariants: boolean; + options: Array<{ + id: string; + name: string; + values: Array<{ + value: string; + slug: string; + }>; + }>; + variants: Array<{ + id: string; + title: string; + options: Array<{ + name: string; + value: string; + slug: string; + }>; + enabled: boolean; + }>; +} +``` + +### Field Mapping + +| UI section | UI control | Form field | Payload field | Transformation | +|---|---|---|---|---| +| General | Title input | `title` | `title` | Trim/validate in form schema if needed; send as product title. | +| General | Handle input | `handle` | `handle` | Auto-generated from title with `slugify`; manual edits are normalized through `slugify`; send as product handle. | +| General | Description editor | `description` | `description` | Convert EditorJS `OutputData` to `{ text, html, json }` using `renderContent`; send `undefined` when empty or no blocks exist. | +| Media | Entity media gallery | `media` | `mediaFileIds` | Extract `ApiFile.id` in the current gallery order; send `undefined` when empty. | +| Variants | Variants switch | `hasVariants` | none | UI-only switch; controls whether `options` and `variants` are included. | +| Variants | Option title input | `options[].name` | `options[].name` | Send only when variants are enabled and the option has a valid name and values. | +| Variants | Option title input | `options[].name` | `options[].slug` | Generate with `slugify(options[].name)` in the payload mapper. | +| Variants | Option values tags | `options[].values[].value` | `options[].values[].name` | Send tag label as API display name. | +| Variants | Option values tags | `options[].values[].slug` | `options[].values[].slug` | Generated with `slugify(value)` when the tag is created; preserve existing slug for unchanged values. | +| Variants | Generated variant row selection | `variants[].enabled` | controls `variants[]` inclusion | Include only selected/enabled generated variants. | +| Variants | Generated variant id | `variants[].id` | `variants[].handle` | Send the generated variant id as API handle; the id is built from option value slugs, for example `red-s`. | + +### Payload Examples + +Simple product without variants: + +```ts +{ + title: "Winter Jacket", + handle: "winter-jacket", + description: { + text: "Warm insulated jacket.", + html: "

Warm insulated jacket.

", + json: { blocks: [...] } + }, + mediaFileIds: ["file_1", "file_2"] +} +``` + +Product with variants: + +```ts +{ + title: "T-Shirt", + handle: "t-shirt", + mediaFileIds: ["file_1"], + options: [ + { + name: "Color", + slug: "color", + values: [ + { name: "Red", slug: "red" }, + { name: "Blue", slug: "blue" } + ] + }, + { + name: "Size", + slug: "size", + values: [ + { name: "S", slug: "s" }, + { name: "M", slug: "m" } + ] + } + ], + variants: [ + { handle: "red-s" }, + { handle: "red-m" }, + { handle: "blue-s" } + ] +} +``` + +### Empty And Disabled State Rules + +- If `description` is `null` or contains no blocks, omit `description`. +- If `media` is empty, omit `mediaFileIds`. +- If `hasVariants` is `false`, omit both `options` and `variants`; the API is expected to create the default variant. +- If `hasVariants` is `true`, the form must contain at least one valid option and at least one enabled generated variant. +- If an option has no name or no values, validation must fail before submit. +- If a generated variant is not enabled, do not include it in `variants`. + +### Product Media Semantics + +Product media is stored on variants, not directly on the product. During `productCreate`, `mediaFileIds` are handled by the backend as variant media: + +- If the product is created without options and variants, the backend creates a default variant and attaches all `mediaFileIds` to that default variant. +- If the product is created with variants, the backend attaches the same `mediaFileIds` list to every created variant. +- File order is persisted through `variant_media.sortIndex`, so the mapper must preserve the gallery order when sending `mediaFileIds`. +- There is no separate featured or primary media field in the current product create API. If the create UI exposes featured selection, it must be treated as ordering only: the featured file should be placed first in `mediaFileIds`. +- The current create API does not support per-variant media selection. Per-variant media requires a variant media update flow after product creation or a backend contract extension. + +### Fields Explicitly Not Sent + +The current create UI does not collect these values, so the create payload must not include them: + +- publish status; +- SEO title, SEO description, and excerpt; +- categories and primary category; +- tags; +- brand; +- SKU; +- price, compare-at price, and cost; +- inventory quantity, warehouse stock, and stock status; +- weight and dimensions; +- per-variant media, SKU, price, stock, weight, or dimensions. + +## Target Product Module Layout + +Create or migrate to this structure: + +```text +src/domains/inventory/products/ + graphql/ + fragments.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-create-product.ts + index.ts + mappers/ + product-create.mapper.ts + product-errors.mapper.ts + index.ts +``` + +Keep `src/domains/inventory/graphql` as a compatibility export while existing imports still depend on it. + +## Integration Phases + +### 1. Move Product GraphQL Operations Into Product Module + +- Create `src/domains/inventory/products/graphql`. +- Move product fragments needed by creation into `products/graphql/fragments.ts`. +- Move `PRODUCT_CREATE_MUTATION` into `products/graphql/mutations.ts`. +- Add `ProductCreateMutationResponse` and `ProductCreateMutationVariables` to `products/graphql/operation-types.ts`. +- Re-export product operations from `products/graphql/index.ts`. +- Re-export from the existing `inventory/graphql/index.ts` temporarily to avoid breaking old imports. + +### 2. Move Payload Preparation Into Mappers + +- Move `prepareProductPayload`, `prepareDescription`, `prepareMediaFileIds`, `prepareOptions`, and `prepareVariants` from modal utils into `products/mappers/product-create.mapper.ts`. +- Keep modal-local variant generation utilities in the modal folder because they are UI behavior, not API mapping. +- Export a single `toProductCreateInput(formValues)` mapper that returns `ApiProductCreateInput`. +- Preserve the current behavior: only enabled variants are sent, media sends only file IDs, and empty optional sections become `undefined`. + +### 3. Normalize API Error Mapping + +- Add `products/mappers/product-errors.mapper.ts`. +- Map API paths like `["input", "title"]`, `["input", "handle"]`, and `["input", "description"]` to `CreateProductFormValues` field names. +- Return unknown field errors as global modal errors. +- Remove inline field-name mapping from `CreateProductModal`. + +### 4. Harden `useCreateProduct` + +- Import mutation and operation types from `products/graphql`. +- Import payload mapper from `products/mappers`. +- Return a stable result shape: + +```ts +{ + product: ApiProduct | null; + userErrors: ApiGenericUserError[]; +} +``` + +- Keep unexpected runtime errors in `error`. +- Add `reset` if the modal needs to clear runtime error state between submits. +- Define refresh behavior after successful create. First implementation can use `refetchQueries` once a real products list query exists. + +### 5. Wire Modal Submit Flow + +- `CreateProductModal` keeps only form submit orchestration. +- On submit: + 1. Validate form via `react-hook-form` and Zod. + 2. Call `createProduct(formValues)`. + 3. Map `userErrors` through `product-errors.mapper.ts`. + 4. Set field errors and show one global message when needed. + 5. On success, close the modal and trigger list refresh/navigation according to final UX decision. +- Disable submit while product creation is loading. +- Ensure media upload UI stores only successfully uploaded `ApiFile` objects in form state. + +### 6. Prepare Product List Refresh + +- Add a real `PRODUCTS_QUERY` before relying on refetch after create. +- Replace `useProducts` mock data with an API-backed hook using Relay connection fields. +- Map `ApiProduct` to a temporary `ProductListItem` view model. +- For fields not present on `ApiProduct`, use documented placeholders or remove columns: + - `name` -> `title` + - `status` -> `isPublished ? "published" : "draft"` + - `inventory` -> not available on `Product`; requires variant stock query or placeholder + - `category` -> not available until category API exists + - `brand` -> not available + - `image` -> not available on `Product`; media currently lives on variants/API-specific media fields + +### 7. Defer Unsupported Mock Fields + +Do not block product creation on fields that are absent from `ProductCreateInput`: + +- categories and primary category +- tags +- price and cost +- inventory quantities +- shipping dimensions and weight, unless variant create/update APIs are added to the flow +- SEO fields, unless the create API adds them or the flow performs a follow-up `productUpdate` + +If these fields must be set during creation, the backend contract needs to be extended or the Admin flow must run follow-up mutations after `productCreate`. + +### 8. Verification + +Per project instructions, do not run tests or `tsc` for this work. + +Use these checks during implementation: + +- Review generated API types in `src/graphql/types.ts`. +- Run codegen only if the schema changes. +- Run `npm run build` when a new code version needs verification. +- Manually verify the modal submit flow against a running GraphQL endpoint. + +## Acceptance Criteria + +- Product create GraphQL operation lives under the product module. +- Product create hook no longer depends on domain-level `inventory/graphql` directly. +- Modal form data is converted to `ApiProductCreateInput` through a module mapper. +- API `userErrors` are mapped through a reusable mapper. +- No API-backed hook imports from `@/mocks`. +- Product creation succeeds with title, handle, description, uploaded media IDs, options, and enabled variants. +- Unsupported mock fields are explicitly excluded from the create payload. +- Product list refresh behavior is defined before list UI is switched from mocks. + +## Main Risks + +- The current Admin GraphQL schema has no category API, so category assignment cannot be integrated yet. +- Product list columns expect fields that `ApiProduct` does not provide directly. +- Media ownership/order semantics depend on how `mediaFileIds` is interpreted by the backend. +- Price, cost, stock, weight, and dimensions belong to variant-related APIs, not the current product create input. diff --git a/admin/docs/product-list-graphql-integration-plan.md b/admin/docs/product-list-graphql-integration-plan.md new file mode 100644 index 000000000..e44faa13f --- /dev/null +++ b/admin/docs/product-list-graphql-integration-plan.md @@ -0,0 +1,348 @@ +# Plan: Product List GraphQL Integration + +## Goal + +Replace the Admin products list mock data with the GraphQL `inventoryQuery.products` connection using the module architecture defined in `knowledge/vault/patterns/admin-graphql-layer.md`. + +The integration must keep the products page API-backed, preserve cursor pagination behavior, provide a stable list view model, support refresh after product creation, and avoid showing mock-only product fields as if they came from the API. + +## Current State + +- `src/domains/inventory/products/hooks/use-products.ts` imports `mockProductsList` from `@/mocks/products/products-list` and simulates an API delay. +- `src/domains/inventory/products/page/page.tsx` imports `IProductListItem` from mocks and renders mock columns: `name`, `status`, `inventory`, `category`, `brand`, and `image`. +- The products page uses static pagination values: `total={50}`, `rangeStart={1}`, `rangeEnd={20}`, `hasNext={true}`, and `hasPrev={false}`. +- Product sorting currently logs AG Grid sort changes and does not call an API. +- Product filters are based on mock fields and options. The current root `products` GraphQL query accepts Relay pagination arguments only. +- Product row click opens the product modal without passing a product id to an API-backed details flow. +- Product picker configuration still reads from `@/mocks/products/products-list`. +- The create product plan depends on a real `PRODUCTS_QUERY` before list refresh after creation can be implemented with `refetchQueries`. + +## Target API Contract + +Use `inventoryQuery.products`: + +```graphql +query Products($first: Int, $after: String, $last: Int, $before: String) { + inventoryQuery { + products(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...ProductListItemFields + } + } + pageInfo { + ...ProductPageInfoFields + } + totalCount + } + } +} +``` + +Initial list fragment: + +```graphql +fragment ProductListItemFields on Product { + id + title + handle + isPublished + publishedAt + createdAt + updatedAt + variantsCount + variants(first: 1) { + edges { + node { + id + isDefault + media { + sortIndex + file { + id + url + mimeType + originalName + } + } + } + } + } +} +``` + +`variants(first: 1)` is a temporary thumbnail source. Product media is stored on variants, and `productCreate` attaches product media to the default variant for simple products or to every created variant for variant products. The list mapper must treat the first media item from the first returned variant as a best-effort thumbnail, not as a product-level media field. + +If the backend needs deterministic thumbnails for all existing products, add a dedicated API field later, such as `Product.thumbnail`, `Product.defaultVariant`, or an ordered product media projection. + +## Target Product Module Layout + +Create or converge to this structure: + +```text +src/domains/inventory/products/ + graphql/ + fragments.ts + queries.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-products.ts + use-create-product.ts + index.ts + mappers/ + product-list.mapper.ts + product-create.mapper.ts + product-errors.mapper.ts + index.ts + page/ + filter-schema.ts + page.tsx +``` + +Keep `src/domains/inventory/graphql` only as a compatibility export while existing imports still depend on it. + +## Product List View Model + +Use an explicit API-backed view model. Do not reuse `IProductListItem` from mocks. + +```ts +export type ProductListStatus = "published" | "draft"; + +export interface ProductListItem { + id: string; + title: string; + handle: string | null; + status: ProductListStatus; + isPublished: boolean; + image: { + id: string; + url: string; + alt: string; + mimeType: string | null; + } | null; + variantsCount: number; + publishedAt: string | null; + createdAt: string; + updatedAt: string; +} +``` + +### Field Mapping + +| UI field | API source | Rule | +|---|---|---| +| `id` | `Product.id` | Keep the GraphQL global id. | +| `title` | `Product.title` | Replaces mock `name`. | +| `handle` | `Product.handle` | Use `null` when absent. | +| `status` | `Product.isPublished` | `true` maps to `published`, `false` maps to `draft`. | +| `image` | `Product.variants.edges[0].node.media[0].file` | Best-effort thumbnail only. Sort media by `sortIndex` before selecting the first item if the response order is not trusted. | +| `variantsCount` | `Product.variantsCount` | Display instead of mock inventory in the first API-backed list. | +| `publishedAt` | `Product.publishedAt` | Keep as API string; format at display boundary if rendered. | +| `createdAt` | `Product.createdAt` | Keep as API string. | +| `updatedAt` | `Product.updatedAt` | Keep as API string. | + +## Fields To Remove Or Defer + +The initial API-backed list must not show these mock-only columns as real API data: + +- `brand` - not available on the current Admin `ApiProduct` type. +- `category` - backend schema has product categories, but current Admin generated `ApiProduct` type does not expose `categories`; do not wire this column until generated Admin types and query support are aligned. +- `inventory` - product-level inventory aggregate is not available on `Product`; it requires a variant inventory aggregate or a dedicated API field. +- `image` as a direct product field - product media is stored on variants, so the list thumbnail must be documented as a variant-media projection. + +Recommended first API-backed columns: + +- Product: image thumbnail and title. +- Status: published or draft. +- Variants: `variantsCount`. +- Updated: formatted `updatedAt`. + +Optional columns after API support: + +- Category: when `Product.categories` is available in Admin generated types and the query can fetch the needed category summary. +- Inventory: when the API exposes product-level stock summary or a cheap variant aggregate. +- Brand: when brand exists as a first-class API field or a documented product feature projection. + +## Integration Phases + +### 1. Add Product List GraphQL Operations + +- Create `src/domains/inventory/products/graphql/fragments.ts` if it does not exist. +- Add `ProductListItemFields`. +- Add a product-local page info fragment such as `ProductPageInfoFields` to avoid depending on media-domain GraphQL files. +- Create `src/domains/inventory/products/graphql/queries.ts`. +- Add `PRODUCTS_QUERY`. +- Create or extend `src/domains/inventory/products/graphql/operation-types.ts`. +- Add `ProductsQueryResponse` and `ProductsQueryVariables` using generated types from `@/graphql/types`. +- Re-export from `src/domains/inventory/products/graphql/index.ts`. + +### 2. Add Product List Mapper + +- Create `src/domains/inventory/products/mappers/product-list.mapper.ts`. +- Export `ProductListItem` and `toProductListItem(product)`. +- Keep all API-to-UI field decisions in the mapper. +- Derive status from `isPublished`. +- Derive thumbnail from variant media and return `null` when no file is available. +- Do not synthesize brand, category, or inventory placeholders in the mapper. +- Export the mapper from `products/mappers/index.ts`. + +### 3. Replace `useProducts` + +- Replace mock loading logic with Apollo `useQuery`. +- Import `PRODUCTS_QUERY` and operation types from `products/graphql`. +- Import `toProductListItem` from `products/mappers`. +- Return the Admin GraphQL layer query shape: + +```ts +interface UseProductsReturn { + items: ProductListItem[]; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => void; +} +``` + +- Support Relay pagination variables: + +```ts +interface UseProductsOptions { + first?: number; + last?: number; + after?: string | null; + before?: string | null; + skip?: boolean; +} +``` + +- Use `fetchPolicy: "cache-and-network"` and `previousData` to avoid grid flicker during pagination. +- Do not import from `@/mocks` in the hook. + +### 4. Wire Products Page To API Data + +- Replace `IProductListItem` with `ProductListItem`. +- Replace `data: products` with `items`, `totalCount`, `pageInfo`, and `loading`. +- Pass `loading` to `AgGridReact`. +- Replace static `DataLayout.count` with `totalCount`. +- Replace static `CursorPagination` values with values derived from `pageInfo`, `items.length`, and local page size state. +- Update `getRowId` to use `ProductListItem.id`. +- Update product cell rendering: + - use `title` instead of mock `name`; + - render `image.url` when present; + - render a compact placeholder when `image` is `null`. +- Update status cell rendering to use `ProductListItem.status`. +- Replace mock columns with supported API columns. +- Pass the selected row id when opening product details: + +```ts +onRowAction: (product) => pushProductModal({ entityId: product.id, mode: "view" }) +``` + +The product modal can remain mock-backed in this plan, but row actions must pass the real product id so the details integration can use it later. + +### 5. Cursor Pagination Behavior + +- Use the same cursor behavior as the media page. +- Store `pageSize`, `after`, `before`, and current page index in page state or reuse `usePageConfig` only for pagination/grid state parts that are supported by the products API. +- On next page, call the hook with `{ first: pageSize, after: pageInfo.endCursor, before: null, last: undefined }`. +- On previous page, call the hook with `{ last: pageSize, before: pageInfo.startCursor, after: null, first: undefined }`. +- Reset cursors to the first page when page size changes. +- Display ranges with the same semantics as `CursorPagination`: empty list starts at `0`, otherwise start is based on current page and page size. + +### 6. Search, Filters, And Sorting + +The current root `products` query accepts only: + +```graphql +first +after +last +before +``` + +Do not wire the existing mock filters to GraphQL in the first API-backed list. They use fields that are not supported by the current products query. + +First implementation: + +- Remove or hide unsupported product filters from the API-backed list page. +- Disable AG Grid server sorting for product columns unless a column has a confirmed query variable. +- Keep the `filter-schema.ts` file only if a supported product filter contract is added. + +Backend/API extension needed for full list controls: + +```graphql +products( + first: Int + after: String + last: Int + before: String + where: ProductWhereInput + orderBy: [ProductOrderByInput!] +): ProductConnection! +``` + +Product sort inputs already exist in the catalog schema, but the root `products` query does not currently expose `where` or `orderBy`. + +### 7. Refresh After Product Create + +After `PRODUCTS_QUERY` exists, product creation can refresh the API-backed list. + +First behavior: + +- On successful `productCreate`, close the create modal. +- Stay on the products list. +- Refresh the active products list query. +- Do not automatically open product details until the product details modal is API-backed. + +Implementation options: + +- Prefer `useCreateProduct` with `refetchQueries: [PRODUCTS_QUERY]` once the query is active and variables are stable enough. +- If active query variables make mutation-level refetch unreliable, pass an explicit `onCreated` callback from `ProductsPage` to `CreateProductModal` and call `refetch()` after success. +- Avoid manual Apollo cache insertion until product pagination, filtering, and sorting contracts are stable. + +### 8. Product Picker Follow-Up + +`src/shared/components/entity-picker-modal/configs/product-picker-config.ts` still imports product mocks. + +Do not block the products page integration on the picker, but plan a follow-up to: + +- reuse `useProducts` or create a picker-specific API hook; +- map `ProductListItem` to `IProductPickerEntity`; +- remove mock-only picker fields such as inventory, category, and brand unless API support exists; +- use the same cursor pagination semantics as the products page. + +### 9. Verification + +Per project instructions, do not run tests or `tsc` for this work. + +Use these checks during implementation: + +- Review generated API types in `src/graphql/types.ts`. +- Run codegen only if the GraphQL schema changes. +- Run `npm run build` when a new code version needs verification. +- Manually verify the products page against a running GraphQL endpoint. +- Manually verify create-product success refreshes the list after `PRODUCTS_QUERY` is introduced. + +## Acceptance Criteria + +- `PRODUCTS_QUERY` lives under `src/domains/inventory/products/graphql`. +- `useProducts` uses Apollo and no longer imports from `@/mocks`. +- Products page uses `ProductListItem`, not `IProductListItem`. +- Products page renders only fields backed by the current API contract or explicitly documented mapper projections. +- Cursor pagination uses `pageInfo` and `totalCount` from `inventoryQuery.products`. +- Product list loading and runtime errors come from the query hook. +- Product create success can refresh the active products list query. +- Row click passes the real product id to the product modal payload. +- Mock-only filters, sorting, category, brand, and inventory columns are not presented as API-backed fields. + +## Main Risks + +- The current root `products` query does not support search, filters, or sorting. +- Product thumbnail is a projection from variant media, not a product-level API field. +- Product inventory requires variant stock aggregation or a dedicated API field. +- Product categories exist in backend schema but are not present on the current Admin generated `ApiProduct` type. +- Product details modal is still mock-heavy, so opening details after create should remain deferred until details are API-backed. diff --git a/admin/docs/product-options-api-integration-plan.md b/admin/docs/product-options-api-integration-plan.md new file mode 100644 index 000000000..b636f88ab --- /dev/null +++ b/admin/docs/product-options-api-integration-plan.md @@ -0,0 +1,862 @@ +# Product Options API Integration Plan + +## Goal + +Replace the Admin product options mock save flow with the current Admin GraphQL API. + +Product options are represented by `ProductOption` in the Admin API: + +- `displayType: OptionDisplayType!` controls how values render in the UI. +- Supported display types are `BUTTONS`, `DROPDOWN`, and `SWATCH`. +- `ProductOption.values` contains the selectable values for that option. +- `ProductOptionSwatch` belongs to option values and is relevant only when the option `displayType` is `SWATCH`. +- The output API exposes option and value `sortIndex`, and the product details fragments currently select it. +- The UI must preserve the rendered order from the returned `options` and `values` arrays. +- The editor may initialize local `sortIndex` from the returned array order for drag-and-drop stability, then generate contiguous sync `sortIndex` values from the current editor order when saving. + +The Admin product details UI already reads options from `ApiProduct.options`. The edit modal must stop using mock options and save the complete option snapshot through `catalogMutation.productOptionsSync`. + +## Baseline + +### Backend API + +The catalog Admin GraphQL schema already exposes product option operations: + +- `productOptionCreate` +- `productOptionUpdate` +- `productOptionDelete` +- `productOptionsSync` + +Use `productOptionsSync` for the first Admin options editor integration. + +Reasons: + +- the current editor works with a complete list snapshot; +- the resolver decodes global product, option, option value, and swatch file IDs before calling the script; +- the script applies a complete replacement transaction; +- options omitted from `input.options` are deleted; +- values omitted from an option `values` list are deleted; +- setting a value `swatch` to `null` removes the swatch; +- the active API e2e contract covers the sync path. + +Do not use the point mutations for this integration. They remain out of scope. + +### Backend Validation Contract + +The UI must assume the server is the source of truth and must surface returned `userErrors`. + +Client validation should catch the same obvious errors before sync: + +- `productId` must be present. +- option and value names must be non-empty after trimming. +- generated option and value slugs must satisfy `/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/`. +- option `sortIndex` values must be non-negative integers. +- value `sortIndex` values must be non-negative integers. +- option `sortIndex` values must be unique. +- value `sortIndex` values must be unique within each option. +- option slugs must be unique within the product. +- value slugs must be unique within each option. +- option IDs must be unique. +- value IDs must be unique across the sync input. +- every option must contain at least one value. +- `displayType` must be one of `BUTTONS`, `DROPDOWN`, or `SWATCH`. +- non-swatch display types must not send hidden value swatches. +- `SWATCH` values must send valid swatch input when the UI shows swatch controls. +- `COLOR` swatches must have `colorOne`. +- `GRADIENT` swatches must have `colorOne` and `colorTwo`. +- `IMAGE` swatches must use an existing API file ID; data URLs must not be sent as `fileId`. +- existing option IDs must belong to the product. +- existing value IDs must belong to the same option. +- a new option must not send existing value IDs. + +The client cannot fully validate database ownership, so it must still keep the modal open and show server `userErrors`. + +### Frontend State + +The product module already has generated API types in `admin/src/graphql/types.ts`, including: + +- `ApiProductOption` +- `ApiProductOptionValue` +- `ApiProductOptionSwatch` +- `ApiProductOptionSwatchInput` +- `ApiProductOptionSyncItemInput` +- `ApiProductOptionValueSyncInput` +- `ApiProductOptionsSyncInput` +- `ApiProductOptionsSyncPayload` +- `OptionDisplayType` +- `SwatchType` + +`ProductOptionFields` already exists and `ProductDetailsFields` already selects `options`. + +Current gaps: + +- `OptionsSection` renders from `product.options`, but returns `null` when the list is empty. +- `OptionsSection` imports display metadata from the edit modal constants, which leaks modal-only code into display UI. +- `ProductDetailsCard` currently renders the options section only for products where `variantsCount > 1`, so adding the first option from an empty product is not exposed. +- `IEditOptionsModalPayload` only carries optional `productId`. +- `EditOptionsModal` accepts `initialGroups` and defaults to `MOCK_OPTION_GROUPS`. +- save only shows `Product option updates are not API-backed yet`. +- `edit-options-modal.schema.ts` re-exports generated API types and aliases API output types as form state. +- `useEditOptionsForm` stores editable state as `ApiProductOption` and `ApiProductOptionValue` objects. +- new options and values currently use local IDs in the same field that persisted API IDs use. +- `SwatchPicker` image upload currently creates a data URL and stores it like a `fileId`; this is not valid for the API sync input. +- there is no `useSyncProductOptions` hook. +- there are no option sync mappers or option-specific `userErrors` mappers. +- there is no active Admin UI e2e coverage for the options modal. + +### Existing E2E Coverage + +Keep `e2e/tests/inventory-api/options-sync.spec.ts` as the API contract test. + +It covers: + +- creating simple options with values; +- creating options with swatches; +- updating option names and display types; +- updating value names; +- adding values; +- reordering options; +- updating swatch values; +- deleting options by omission; +- deleting all options with an empty list; +- deleting values by omission; +- removing swatches by sending `swatch: null`; +- invalid product errors; +- duplicate option slugs; +- duplicate option `sortIndex` values; +- duplicate value slugs within an option; +- options without values; +- invalid existing option IDs; +- invalid new option value references; +- full structure replacement; +- complex reorder with modifications; +- preserving option and value IDs on update; +- all display types. + +Add Admin UI e2e coverage only after the modal is API-backed. + +## Integration Rules + +Follow `knowledge/vault/patterns/admin-graphql-layer.md`. + +Required rules for this work: + +- Import generated API types directly from `@/graphql/types` at the usage site. +- Do not re-export generated API types from module barrels, component barrels, `graphql/index.ts`, `operation-types.ts`, modal schema files, or feature-local `types.ts`. +- Do not create API output view models for `ProductOption`. +- API-backed display components must accept generated API data directly. +- `OptionsSection` must accept `options: ApiProductOption[]`. +- UI-local editor groups and values are allowed only inside the edit modal boundary. +- Mappers may convert `ApiProductOption[]` to modal-local editor groups and editor groups back to `ApiProductOptionsSyncInput`. +- Mappers must not perform GraphQL calls. +- Hooks own Apollo calls and normalize returned `userErrors`. +- Components must not inspect raw payload paths like `data.catalogMutation.productOptionsSync`. +- The mutation freshness strategy must be explicit. For this integration, use the modal payload `onSaved` callback to refetch product details and variant data. +- Do not use mocks in new API-backed hooks. + +## Target File Layout + +Add or update files under the existing product module: + +```text +admin/src/domains/inventory/products/ + graphql/ + fragments.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-sync-product-options.ts + index.ts + mappers/ + product-options.mapper.ts + product-option-errors.mapper.ts + index.ts + components/ + product-details-card/ + sections/ + options-section.tsx + modals/ + edit-options-modal/ + edit-options-modal.tsx + types.ts + edit-options-modal.schema.ts + edit-options-modal.constants.tsx + hooks/ + use-edit-options-form.ts + components/ +``` + +Replace `IOptionGroup` and `IOptionValue` API aliases with modal-local editor types. Whether the old names are temporarily kept or renamed, they must not be generated API type aliases and must not be used as API-backed display prop contracts. + +## GraphQL Changes + +### Product Option Fragment + +Keep focused option fragments in `products/graphql/fragments.ts`. + +Required fields: + +```graphql +fragment ProductOptionValueFields on ProductOptionValue { + id + name + slug + sortIndex + swatch { + id + swatchType + colorOne + colorTwo + metadata + file { + ...FileFields + } + } +} + +fragment ProductOptionFields on ProductOption { + id + name + slug + displayType + sortIndex + values { + ...ProductOptionValueFields + } +} +``` + +The schema exposes option and value `sortIndex`, and the current product fragments already select it. Display order must still follow the returned `options` and `values` array order. Editor state should derive contiguous local `sortIndex` values from the returned array positions, not trust sparse or stale backend indexes for drag state. The sync mapper must generate contiguous output `sortIndex` values from the final editor order. + +`slug` is still required by the current schema and sync input. If the backend removes option and value slugs before this UI work lands, regenerate types and update this fragment, mapper, validation, and e2e assertions in the same implementation. + +### Sync Mutation + +Add `PRODUCT_OPTIONS_SYNC_MUTATION` to `products/graphql/mutations.ts`. + +Also import `PRODUCT_OPTION_FRAGMENT` from `./fragments`. + +```graphql +mutation ProductOptionsSync($input: ProductOptionsSyncInput!) { + catalogMutation { + productOptionsSync(input: $input) { + product { + id + options { + ...ProductOptionFields + } + } + options { + ...ProductOptionFields + } + userErrors { + ...UserErrorFields + } + } + } +} +``` + +Append `${PRODUCT_OPTION_FRAGMENT}` and `${USER_ERROR_FRAGMENT}` to the document. + +Select only the fields needed to refresh the options area. Do not request the full product details payload or variants in this mutation. Product details and variants freshness must be handled by the modal payload `onSaved` callback. + +### Operation Types + +Update `products/graphql/operation-types.ts` with local operation response and variables types built from generated schema types. + +Do not re-export generated schema types. + +Use a partial product type because the sync mutation selects only `id` and `options`: + +```ts +import type { + ApiCatalogMutation, + ApiGenericUserError, + ApiProduct, + ApiProductOption, + ApiProductOptionsSyncInput, +} from "@/graphql/types"; + +export type ProductOptionsSyncProduct = Pick & { + options: ApiProductOption[]; +}; + +export interface ProductOptionsSyncPayloadData { + product: ProductOptionsSyncProduct | null; + options: ApiProductOption[]; + userErrors: ApiGenericUserError[]; +} + +export interface ProductOptionsSyncMutationData { + catalogMutation: Pick & { + productOptionsSync: ProductOptionsSyncPayloadData; + }; +} + +export interface ProductOptionsSyncMutationVariables { + input: ApiProductOptionsSyncInput; +} +``` + +Export these local operation types from `products/graphql/index.ts`. This is allowed because they are operation-local types, not generated API type re-exports. + +## Editor State And Mapper Plan + +Create `product-options.mapper.ts`. + +### Editor Types + +Keep editor types in `modals/edit-options-modal/types.ts`. + +Recommended shape: + +```ts +export interface OptionEditorSwatch { + swatchType: SwatchType; + colorOne?: string | null; + colorTwo?: string | null; + fileId?: string | null; + metadata?: unknown; +} + +export interface OptionEditorValue { + id: string; + apiId?: string; + apiSwatchId?: string; + name: string; + slug: string; + sortIndex: number; + swatch: OptionEditorSwatch | null; +} + +export interface OptionEditorGroup { + id: string; + apiId?: string; + name: string; + slug: string; + displayType: OptionDisplayType; + sortIndex: number; + values: OptionEditorValue[]; +} +``` + +Rules: + +- `id` is the UI identity. +- `apiId` is the persisted global API ID. +- Existing API options use `id = apiId = option.id`. +- New options use temporary IDs such as `tmp-option-${crypto.randomUUID()}` and no `apiId`. +- Existing values use `id = apiId = value.id`. +- New values use temporary IDs such as `tmp-option-value-${crypto.randomUUID()}` and no `apiId`. +- Sync input sends only `apiId`, never temporary IDs. +- `apiSwatchId` is optional metadata for local tracking only. Sync input cannot preserve a swatch ID because `ProductOptionSwatchInput` has no `id`. +- Modal-local types may reference generated enums such as `OptionDisplayType` and `SwatchType`, but must not re-export them. + +### API To Editor Groups + +Function: + +```ts +apiProductOptionsToOptionEditorGroups( + options: ApiProductOption[], +): OptionEditorGroup[] +``` + +Rules: + +- Treat API options as the server-valid source of truth. +- Preserve option order from the `options` array. +- Preserve value order from each option `values` array. +- Set each option editor `sortIndex` from its current array position. +- Set each value editor `sortIndex` from its current array position. +- Preserve API `sortIndex` only as server output data; do not use it as the source of truth for editor ordering. +- Preserve option and value `apiId`. +- Convert output swatches to editor swatches. +- For image swatches, use `swatch.file?.id` as `fileId`. +- Do not create data URL `fileId` values. +- If the output contains swatches for a non-`SWATCH` display type, keep them out of the save set unless the user changes the option back to `SWATCH`. + +### Editor Groups To Sync Input + +Prefer a builder that returns both the API input and field path metadata for error mapping. + +Function: + +```ts +interface ProductOptionsSyncDraft { + input: ApiProductOptionsSyncInput; + optionGroupsByInputIndex: OptionEditorGroup[]; + valuesByInputPath: Record; +} + +buildProductOptionsSyncDraft(input: { + productId: string; + groups: OptionEditorGroup[]; +}): ProductOptionsSyncDraft +``` + +Rules: + +- Build a complete snapshot for all editor groups. +- Options are sorted by local editor `sortIndex` and receive contiguous sync `sortIndex` values `0`, `1`, `2`. +- Values are sorted by local editor `sortIndex` within each option and receive contiguous sync `sortIndex` values `0`, `1`, `2`. +- Send option `id` only when `group.apiId` is present. +- Send value `id` only when `value.apiId` is present. +- Preserve existing option slugs from API-backed rows unless the user explicitly changes slug editing behavior in the future. +- Preserve existing value slugs from API-backed rows unless the user explicitly changes slug editing behavior in the future. +- Generate option slugs from group names only for new groups or existing groups with an empty/missing slug. +- Generate value slugs from value names only for new values or existing values with an empty/missing slug. +- Option slugs must be unique across the product. De-duplicate generated slugs with numeric suffixes. +- Value slugs must be unique within the option. De-duplicate generated slugs with numeric suffixes. +- The slug generator must never return an empty string. Use a deterministic fallback such as `option-${position + 1}` or `value-${position + 1}`. +- Preserve existing option and value IDs on update. +- New options and values are created by omitting IDs. +- Deleted options and values are omitted from the complete snapshot. +- Every option must send at least one value. +- For `SWATCH` options, send value swatches from the editor state. +- For non-`SWATCH` options, send `swatch: null` for every value to remove hidden stale swatches. +- Existing image swatches may be preserved only when `fileId` is an existing API file global ID. +- New image swatches require real media upload integration before sync. Do not send data URLs. + +Also export a convenience wrapper only if needed: + +```ts +optionEditorGroupsToProductOptionsSyncInput(input: { + productId: string; + groups: OptionEditorGroup[]; +}): ApiProductOptionsSyncInput +``` + +### Slug Generation + +Use the project's existing `slugify` approach from product create or product features as the base implementation. + +Required helper behavior: + +```ts +toOptionSlug(name: string, fallback: string): string +toOptionValueSlug(name: string, fallback: string): string +toUniqueSlug(slug: string, used: Set): string +``` + +Rules: + +- transliterate names; +- lowercase; +- replace invalid separators with `-`; +- collapse repeated hyphens; +- trim leading and trailing hyphens; +- use fallback when the result is empty; +- ensure the final value matches the backend-compatible slug regex; +- suffix duplicates as `slug-2`, `slug-3`, etc. + +### Client Validation + +Function: + +```ts +validateOptionEditorGroups(input: { + productId: string; + groups: OptionEditorGroup[]; +}): ApiGenericUserError[] +``` + +Validation must run before calling `productOptionsSync`. + +It must detect: + +- missing product ID; +- empty option name; +- empty value name; +- invalid generated option slug; +- invalid generated value slug; +- invalid display type; +- duplicate option row IDs; +- duplicate option API IDs; +- duplicate value row IDs; +- duplicate value API IDs; +- duplicate option `sortIndex` positions after drag; +- duplicate value `sortIndex` positions after drag; +- duplicate option slugs; +- duplicate value slugs within an option; +- option without values; +- new option containing a persisted value API ID; +- invalid `COLOR` swatch; +- invalid `GRADIENT` swatch; +- invalid `IMAGE` swatch; +- non-swatch option retaining value swatches in the input draft. + +Validation errors must use these server-like field paths: + +- `["productId"]` +- `["options", optionIndex, "name"]` +- `["options", optionIndex, "slug"]` +- `["options", optionIndex, "sortIndex"]` +- `["options", optionIndex, "displayType"]` +- `["options", optionIndex, "values"]` +- `["options", optionIndex, "values", valueIndex, "name"]` +- `["options", optionIndex, "values", valueIndex, "slug"]` +- `["options", optionIndex, "values", valueIndex, "sortIndex"]` +- `["options", optionIndex, "values", valueIndex, "swatch"]` + +Server `userErrors` remain authoritative and must still be displayed. + +## Hook Plan + +Create `use-sync-product-options.ts`. + +Target contract: + +```ts +interface SyncProductOptionsResult { + product: ProductOptionsSyncProduct | null; + options: ApiProductOption[]; + userErrors: ApiGenericUserError[]; +} + +interface UseSyncProductOptionsReturn { + syncProductOptions: ( + input: ApiProductOptionsSyncInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} +``` + +Implementation: + +- use `useMutation(PRODUCT_OPTIONS_SYNC_MUTATION)`; +- unwrap `payload.product`, `payload.options`, and `payload.userErrors`; +- return `product: null`, `options: []`, and a normalized `UNEXPECTED_ERROR` user error for unexpected exceptions; +- expose Apollo `loading`, `error`, and `reset`; +- do not expose raw nested payload paths to components; +- do not import mocks; +- do not perform a broad cache write in the first implementation; +- document that freshness is handled by the modal payload `onSaved` callback. + +The hook may optionally accept future options for `refetchQueries` or cache updates, but the first integration should keep refresh behavior explicit through `onSaved`. + +## UI Integration Plan + +### Product Details Read Path + +`ProductDetailsCard` already passes `product.options` to `OptionsSection`. Update the surrounding UI so the API-backed edit path is reachable and not tied to mock state. + +Required changes: + +- update `OptionsSection` props to accept `options: ApiProductOption[]` and `actions?: ReactNode`; +- pass an `EditAction` with `testId="product-options-actions-button"` from `ProductDetailsCard`; +- keep the options section visible when `options.length === 0` and edit actions exist; +- do not hide the only edit entry point solely because `variantsCount <= 1`; +- make the product type gating explicit: + - render the options section for all products if adding the first option is supported; + - keep the variants table gated by actual variant data; +- remove the display component dependency on `edit-options-modal.constants.tsx`; +- keep display metadata in the section, a small display helper, or another non-modal shared file; +- keep product details options sourced from `product.options`, not mocks. + +`OptionsSection` must not import modal editor group types. + +Target props: + +```ts +interface OptionsSectionProps { + options: ApiProductOption[]; + actions?: ReactNode; +} +``` + +Display rules: + +- preserve option order from the `options` array; +- preserve value order from each option `values` array; +- show option display type affordance using `displayType`; +- show value names as tags; +- show swatch previews only for `displayType === OptionDisplayType.Swatch`; +- handle `COLOR`, `GRADIENT`, and `IMAGE` swatches; +- show an empty state for no options instead of returning `null` when an edit action is available; +- keep the section visible when `options.length === 0` if `actions` exists, so users can create the first option. + +### Modal Payload + +Extend `IEditOptionsModalPayload`: + +```ts +interface IEditOptionsModalPayload extends IModalStackPayload { + productId: string; + options: ApiProductOption[]; + onSaved?: () => Promise | unknown; +} +``` + +Import `ApiProductOption` directly from `@/graphql/types`. + +Update `useProductModals` to pass product options and the refresh callback: + +```ts +const handleEditOptions = useCallback(() => { + openEditOptionsModal({ + productId: product.id, + options: product.options, + onSaved: options.onProductRefresh, + }); +}, [ + product.id, + product.options, + options.onProductRefresh, + openEditOptionsModal, +]); +``` + +Do not omit the refresh callback. Option sync can change product options and product variants, so the product details view must be refetched after a successful save. + +### Edit Options Modal + +Update `EditOptionsModal`: + +- read typed payload from modal stack context; +- require `productId`; +- initialize groups from `payload.options` through `apiProductOptionsToOptionEditorGroups`; +- remove `MOCK_OPTION_GROUPS` import; +- remove or stop using the `initialGroups` mock default for app code; +- replace generated API type aliases in `edit-options-modal.schema.ts` with modal-local editor types or remove the schema file if it no longer owns useful runtime validation; +- keep option and value edits in modal-local state; +- keep `react-hook-form` and `dnd-kit` if they remain compatible with the editor type shape; +- create temporary IDs for new options and values; +- add a new option with a default display type and at least one empty value row; +- preserve option and value IDs according to `apiId` rules; +- preserve existing image swatches only when they have a real `file.id`; +- do not send data URLs as swatch `fileId`; +- hide or disable new image swatch upload until media upload integration provides real file IDs; +- run `validateOptionEditorGroups` before sync; +- build the sync draft with `buildProductOptionsSyncDraft`; +- call `useSyncProductOptions().syncProductOptions(draft.input)`; +- disable submit while saving; +- introduce explicit modal dirty state, because the current options modal does not have one; +- set dirty state to `true` whenever option, value, display type, swatch, order, add, or delete actions change editor state; +- keep dirty state `false` immediately after initialization from `payload.options`; +- show client validation errors in the modal; +- show API `userErrors` in the modal; +- keep the modal open on validation errors or API errors; +- do not clear dirty state on failed save; +- after successful save, await `onSaved?.()`; +- after refresh completes, call `setDirty(false)` and close the modal; +- show a success notification only after the mutation succeeds and there are no `userErrors`. + +Submit flow: + +```ts +const handleSave = async () => { + const validationErrors = validateOptionEditorGroups({ productId, groups }); + if (validationErrors.length > 0) { + setUserErrors(validationErrors); + return; + } + + const draft = buildProductOptionsSyncDraft({ productId, groups }); + const result = await syncProductOptions(draft.input); + + if (result.userErrors.length > 0) { + setUserErrors(result.userErrors); + return; + } + + await payload.onSaved?.(); + setDirty(false); + pop(); +}; +``` + +### Drag And Ordering Constraints + +Keep using `dnd-kit` only if it preserves the required structure. + +Required behavior: + +- options are a flat sortable list; +- values are sortable only within their own option; +- values must not move across options unless the UI explicitly implements that workflow; +- deleting an option deletes its values from modal state; +- deleting the last value should be blocked or immediately replaced with an empty value row; +- local editor `sortIndex` values are normalized after each drag; +- final sync payload is built from normalized groups and values, not from raw DOM order. + +Even if drag guards are present, save must still run `validateOptionEditorGroups`. + +## Error Handling Plan + +Create `product-option-errors.mapper.ts`. + +Minimum required exports: + +```ts +formatProductOptionUserError(error: ApiGenericUserError): string +formatProductOptionUserErrors(errors: ApiGenericUserError[]): string[] +``` + +Optional field-level exports: + +```ts +mapProductOptionUserErrorsToEditorErrors(input: { + errors: ApiGenericUserError[]; + draft: ProductOptionsSyncDraft; +}): ProductOptionEditorErrorMap +``` + +Minimum first-pass behavior: + +- render all client validation and API `userErrors` in a modal-level `Alert`; +- include every error message, not only the first one; +- keep the modal open on errors; +- do not clear dirty state on errors. + +Field-level rendering can be added in the same implementation if it is cheap: + +- map paths like `["options", "0", "name"]` to `draft.optionGroupsByInputIndex[0]`; +- map paths like `["options", "0", "values", "1", "name"]` through `draft.valuesByInputPath`; +- highlight affected option or value inputs. + +Do not inline repeated field path parsing inside React components. + +## E2E Plan + +Do not replace `e2e/tests/inventory-api/options-sync.spec.ts`. + +Add focused Admin UI coverage after the modal is API-backed: + +```text +e2e/tests/admin-ui/product-update-options.spec.ts +``` + +Add stable test IDs needed by the spec: + +- `product-options-section` +- `product-options-empty-state` +- `product-options-actions-button` +- `edit-options-modal` +- `edit-options-list` +- `edit-options-add-button` +- `edit-options-option-card` +- `edit-options-option-name-input` +- `edit-options-display-type-trigger` +- `edit-options-add-value-button` +- `edit-options-value-row` +- `edit-options-value-name-input` +- `edit-options-swatch-trigger` +- `submit-edit-options-form-button` +- `edit-options-error-alert` + +Recommended scenarios: + +- create a product with no options, open details, assert the options empty state is visible, add the first option with values, save, reload, assert it is shown; +- add a `SWATCH` option with color and gradient values, save, reload, assert swatches render; +- edit option names, display types, and value names, save, reload, assert changes persist; +- reorder options and values, save, reload, assert ordering; +- delete a value and delete an option, save, reload, assert removed items are gone; +- submit invalid rows and assert the modal shows errors without closing; +- edit an existing option and value, save, assert option and value IDs are preserved through an API read; +- change a `SWATCH` option to `DROPDOWN`, save, reload, assert swatches are removed or no longer rendered according to the chosen save behavior. + +Per project instructions, e2e specs should be run one file at a time only when verification is explicitly needed. Do not run `test` or `tsc` for this planning task. + +## Implementation Phases + +### Phase 1: GraphQL Operation And Types + +- Confirm `ProductOptionFields` has all required fields. +- Confirm `ProductOptionValueFields` selects enough swatch and file data to preserve existing swatches. +- Add `PRODUCT_OPTIONS_SYNC_MUTATION`. +- Add local operation data, payload, product, and variables types. +- Export operation documents and operation-local types from existing product GraphQL barrels. +- Do not re-export generated API types. + +### Phase 2: Editor Types And Mappers + +- Replace API output form aliases with modal-local `OptionEditorGroup` and `OptionEditorValue`. +- Add API-to-editor mapper. +- Add swatch output-to-input mapper. +- Add sync draft builder. +- Add slug generation helpers. +- Add client validation helpers. +- Add error formatting and optional field path mapping. + +### Phase 3: Product Details Read Path + +- Change `OptionsSection` to accept `actions?: ReactNode`. +- Keep `OptionsSection` API-backed with `ApiProductOption[]`. +- Move display type metadata out of modal-only constants. +- Keep the section visible with an empty state when edit actions exist. +- Render product details options from `product.options`. +- Make option section gating explicit so the first option can be created when supported. + +### Phase 4: Modal API Integration + +- Extend options modal payload. +- Pass product options and refresh callback through `useProductModals`. +- Replace mock initialization with API option groups. +- Implement add option and add value with temporary IDs. +- Implement display type and swatch editing against modal-local state. +- Disable or defer new image swatch upload until it can produce real API file IDs. +- Implement validation, sync draft build, and `productOptionsSync` save. +- Keep modal open on validation or API errors. +- Close only after successful sync and refresh callback completion. + +### Phase 5: Refresh Strategy + +- Use `onSaved` product details refetch for the first implementation. +- Ensure the refresh also updates variant data affected by option sync. +- Do not write brittle manual cache updates for the paginated product details or variants queries in this pass. +- Consider a focused Apollo cache update later only after product details and variants cache behavior is stable. + +### Phase 6: Admin UI E2E Coverage + +- Keep API e2e as the sync semantics contract. +- Add one focused Admin UI spec for product option editing. +- Run only that one spec when verification is explicitly requested. +- Do not broaden the test run. + +## Out Of Scope + +- Backend schema changes. +- Adding new option or value `index` fields to the output schema. +- Removing `slug` from option contracts. +- Fixing or using `productOptionCreate`, `productOptionUpdate`, or `productOptionDelete`. +- Reworking product variant generation beyond the behavior already provided by option sync. +- Reworking the variants editor. +- Replacing attributes, reviews, bundles, analytics, or other unrelated mock-only islands. +- Reworking the options modal design beyond what is required for API-backed editing. +- New image swatch upload unless a real media upload flow is wired in the same implementation. +- Manual Apollo cache updates for product details or variant pagination. + +## Acceptance Criteria + +- Product details options render from `product.options`. +- `OptionsSection` accepts generated API data directly and does not import modal editor types. +- `OptionsSection` no longer imports modal-only constants for display metadata. +- Product details exposes an option edit action when the options list is empty and options editing is supported. +- `IEditOptionsModalPayload` carries `productId`, `options`, and `onSaved`. +- `EditOptionsModal` opens with the current product options from payload. +- `EditOptionsModal` no longer imports `MOCK_OPTION_GROUPS`. +- Generated API types are no longer re-exported from `edit-options-modal.schema.ts`. +- Modal form state uses local editor types, not `ApiProductOption` output objects. +- New options and values use temporary local IDs. +- Existing option and value global IDs are stored separately as `apiId`. +- Save calls `catalogMutation.productOptionsSync` with a complete snapshot. +- Existing option and value IDs are preserved on update. +- New options and values are created by omitting IDs. +- Deleted options and values are omitted from the snapshot and removed by the API. +- Option and value `sortIndex` values are generated from current editor order. +- Slugs are generated, valid, non-empty, and de-duplicated. +- Non-swatch options do not send hidden swatches. +- Existing image swatches are preserved only through real API file IDs. +- Data URLs are never sent as swatch `fileId`. +- Client validation blocks invalid structure before the mutation. +- API `userErrors` are visible and prevent closing the modal. +- Failed saves do not clear dirty state. +- Successful save awaits product refresh, clears dirty state, and closes the modal. +- No generated API types are re-exported through local barrels. +- No API output view model is introduced for product options. +- Admin UI e2e coverage is added for the API-backed options workflow. diff --git a/admin/docs/product-raw-api-contracts-mock-migration-plan.md b/admin/docs/product-raw-api-contracts-mock-migration-plan.md new file mode 100644 index 000000000..a30b0a0d1 --- /dev/null +++ b/admin/docs/product-raw-api-contracts-mock-migration-plan.md @@ -0,0 +1,650 @@ +# Plan: Product API Integration And Raw API Contract Migration + +## Goal + +Finish the Admin product UI migration from mock-only contracts to the current GraphQL Admin API contract. + +The product UI must consume generated GraphQL API shapes directly: + +- `ApiProduct` +- `ApiVariant` +- `ApiCategory` +- `ApiTag` +- related nested API types from `@/graphql/types` + +The migration must not introduce product output view models such as `ProductListItem`, `ProductDetailsView`, `toProductListItem`, `toProductDetailsView`, or mock-to-API adapters. UI-local row and form models are allowed only inside editor, picker, and modal boundaries where the UI needs editable draft state. + +This plan supersedes older product list/create integration plans when they conflict with `knowledge/vault/patterns/admin-graphql-layer.md`. + +## Refreshed Repository Baseline + +This baseline was refreshed against the current code before updating this plan. + +### Generated API Types And Current Schema + +`admin/src/graphql/types.ts` is no longer in the stale state described by the previous version of this plan. The current generated Admin frontend types already include: + +- `ApiCatalogQuery` +- `ApiCatalogMutation` +- `ApiProduct` +- `ApiProductConnection` +- `ApiVariant` +- `ApiVariantConnection` +- `ApiCategory` +- `ApiCategoryConnection` +- `ApiTag` +- `ApiProductSeo` +- `ApiInventoryItem` +- `ApiProductInventoryWidget` + +The current product API is under the catalog namespace: + +- list/details queries: `catalogQuery.product`, `catalogQuery.products` +- product mutations: `catalogMutation.productCreate`, `catalogMutation.productUpdate`, `catalogMutation.productDelete`, `catalogMutation.productUpdateStatus` + +Do not use `inventoryQuery.products` or `inventoryMutation.productCreate` for product UI integration. + +The current `ApiProduct` shape includes `categories`, `tags`, `features`, `seo`, `revision`, `media`, `variants`, and `variantsCount`. The current `ApiVariant` shape includes inventory data through `variant.inventoryItem`; stale flattened variant fields such as `variant.sku`, `variant.stock`, `variant.weight`, `variant.dimensions`, `variant.cost`, and `variant.inStock` must not be used. + +`CatalogQuery.products` currently accepts Relay pagination arguments only: + +```ts +first?: number; +after?: string; +last?: number; +before?: string; +``` + +Search, filters, and sorting are not available in the current generated product list query contract. Do not pretend those controls are server-backed until the API adds query arguments for them. + +### Existing Product UI State + +The product UI is partially migrated: + +- `admin/src/mocks/products/api-builders.ts` already builds generated API-shaped mocks. +- `admin/src/mocks/products/data.ts` exports `mockSimpleProduct`, `mockVariableProduct`, `mockDraftProduct`, and `mockArchivedProduct` as `ApiProduct`. +- `admin/src/mocks/products/products-list.ts` exports `mockProductsList: ApiProduct[]` and `mockProductsConnection: ApiProductConnection`. +- `admin/src/mocks/products/categories.ts` exports `mockCategories: ApiCategory[]`. +- `admin/src/domains/inventory/products/hooks/use-products.ts` returns `ApiProduct[]`, but it still imports `mockProductsList` and simulates pagination with local page numbers. +- `admin/src/domains/inventory/products/page/page.tsx` already renders AG Grid rows as `ApiProduct`; it still relies on the mock-backed `useProducts` hook. +- `admin/src/shared/components/entity-picker-modal/configs/product-picker-config.ts` adapts `ApiProduct` to private picker rows, but it still imports `mockProductsList` directly. +- `admin/src/shared/components/entity-picker-modal/configs/category-picker-config.ts` already adapts `ApiCategory`. +- `ProductModal` resolves details through `findMockProductById`, `productDetailsMockData`, and `getMockVariantsTableData`. +- `ProductDetailsCard`, product header/content, product media, category, inventory, shipping, and variants sections mostly consume `ApiProduct`, `ApiVariant`, and `ApiCategory`. +- `EditVariantsModal` correctly accepts `ApiVariant[]` and converts them to modal-local editable rows inside the modal. +- `useCreateProduct` calls `catalogMutation.productCreate`, but product GraphQL documents still live in `admin/src/domains/inventory/graphql` instead of the module-local `products/graphql` layout. + +### Remaining Mock And Legacy Islands + +The following remaining mock/legacy areas are expected at this point: + +- Product list hook and product picker are mock-backed. +- Product details modal is mock-backed. +- Product details supplemental data remains mock-only for reviews, analytics, bundles, pricing templates, dependency rules, attributes, and aggregate inventory widgets. +- Bulk editor uses `admin/src/mocks/products/bulk-editor.ts` and local mock contracts; it is a separate island unless explicitly included in the product API integration scope. +- `admin/src/mocks/products/types.ts` still exists for legacy and bundle mocks. Do not delete it merely because the product UI migration exists. Delete or shrink it only after all active consumers are migrated. + +## Integration Rules + +Follow `knowledge/vault/patterns/admin-graphql-layer.md`. + +- Components must consume generated API-shaped data directly. +- Generated API types must be imported directly from `@/graphql/types`. +- Module barrels must not re-export generated API types. +- Hooks may unwrap GraphQL root payloads into stable fields such as `products`, `product`, `pageInfo`, and `userErrors`. +- Hooks must not map API output objects into product-specific output view models. +- Mappers may convert form/editor state into API inputs and API `userErrors` into form errors. +- Picker-local `IPickableEntity` rows are allowed only inside picker config boundaries. +- Variant editor rows are allowed only inside the variants editor boundary. +- Do not cast partial GraphQL query results to `ApiProduct` unless the operation selects every field that downstream components read. Either select the required fields in focused fragments or introduce operation-derived result types when operation codegen is available. + +## Product Media Semantics + +The current API exposes both product media and variant media: + +- `Product.media: ProductMediaItem[]` +- `Variant.media: VariantMediaItem[]` + +Use `product.media` as the product gallery and product thumbnail source for product list and product details media. Use `variant.media` only for variant-specific UI, such as variant rows and variant media editing. + +The first product media item by `sortIndex` is the current product thumbnail display fallback. It is not a separate featured media API field. If the backend later adds explicit featured/thumbnail semantics, update `getProductThumbnailFile` and related UI helpers instead of adding a view model. + +## Product Status Semantics + +The UI may keep a local `ProductStatus` display union derived from API fields: + +- `published` from `product.isPublished === true` +- `draft` from `product.isPublished === false` +- `archived` from `product.deletedAt != null` + +This local display union must not reintroduce legacy `EntityStatus` or legacy mock product status fields. Active product lists should exclude soft-deleted products unless the screen intentionally renders deleted products. + +## Target Product Module Layout + +Converge the product module to: + +```text +admin/src/domains/inventory/products/ + graphql/ + fragments.ts + queries.ts + mutations.ts + operation-types.ts + index.ts + hooks/ + use-products.ts + use-product.ts + use-create-product.ts + use-update-product.ts + use-delete-product.ts + index.ts + mappers/ + product-create.mapper.ts + product-errors.mapper.ts + index.ts + page/ + modals/ + components/ + utils/ +``` + +`admin/src/domains/inventory/graphql` may remain as a compatibility barrel during migration, but product-specific operation documents should live under `products/graphql`. + +## Target GraphQL Operations + +### Shared Fragments + +Create focused fragments under `products/graphql/fragments.ts`. + +Minimum shared fragments: + +- `UserErrorFields` +- `FileFields` +- `RichTextFields` +- `ProductMediaItemFields` +- `VariantMediaItemFields` +- `InventoryItemFields` +- `VariantFields` +- `ProductOptionFields` +- `ProductCategoryFields` +- `ProductTagFields` +- `ProductListFields` +- `ProductDetailsFields` + +`ProductListFields` must include every field currently read by the product grid and picker: + +- `id` +- `title` +- `handle` +- `isPublished` +- `publishedAt` +- `createdAt` +- `updatedAt` +- `deletedAt` +- `revision` +- `variantsCount` +- `media { sortIndex file { ...FileFields } }` +- `categories { id name handle isPublished productsCount media { sortIndex file { ...FileFields } } }` +- `features { id name slug values { id name slug } }` +- enough variant data to derive list stock/price if the list continues to display those columns + +`ProductDetailsFields` must include every field read by `ProductDetailsCard` and its descendants: + +- all product list fields +- `description` +- `excerpt` +- `seo` +- `tags` +- `options` +- `variants` with `selectedOptions`, `media`, `price`, and `inventoryItem` + +If the details screen keeps variant pagination, fetch variants through either a focused product details query with variable variant pagination or a dedicated variant query. Do not pass unrelated mock `getMockVariantsTableData` into API-backed product details. + +### Product List Query + +Use the current catalog query namespace: + +```graphql +query Products($first: Int, $after: String, $last: Int, $before: String) { + catalogQuery { + products(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...ProductListFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +} +``` + +Do not add UI-only `page`, `pageSize`, `search`, `sort`, or `filters` variables to the GraphQL operation until the backend schema exposes those arguments. + +### Product Details Query + +Use: + +```graphql +query ProductDetails($id: ID!, $variantsFirst: Int, $variantsAfter: String) { + catalogQuery { + product(id: $id) { + ...ProductDetailsFields + } + } +} +``` + +If the current schema cannot parameterize nested `variants` through variables in the fragment cleanly, keep the variant connection in the query body and make the fragment cover variant node fields. + +### Product Mutations + +Move existing product mutation documents from `admin/src/domains/inventory/graphql` into `products/graphql/mutations.ts`: + +- `PRODUCT_CREATE_MUTATION` +- `PRODUCT_UPDATE_MUTATION` +- `PRODUCT_DELETE_MUTATION` +- `PRODUCT_UPDATE_STATUS_MUTATION` +- product option and variant media mutations when used by product edit flows + +Keep compatibility re-exports from `admin/src/domains/inventory/graphql` until existing imports are migrated. + +## Target Hook Contracts + +### Product List Hook + +`useProducts` should become API-backed: + +```ts +interface UseProductsOptions { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + skip?: boolean; +} + +interface UseProductsReturn { + products: ApiProduct[]; + connection: ApiProductConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} +``` + +The return type may use operation-derived product node types if operation codegen is introduced. It must not use a custom product output view model. + +### Product Details Hook + +Add `useProduct`: + +```ts +interface UseProductOptions { + id: string | null; + variantsFirst?: number; + variantsAfter?: string | null; + skip?: boolean; +} + +interface UseProductReturn { + product: ApiProduct | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} +``` + +The hook should unwrap `data.catalogQuery.product` and not expose the raw nested operation path to components. + +### Product Create Hook + +Keep `CreateProductInput` as a UI form input type. Convert it to `ApiProductCreateInput` in a mapper. + +`useCreateProduct` should return: + +```ts +interface UseCreateProductReturn { + createProduct: (input: CreateProductInput) => Promise<{ + product: ApiProduct | null; + userErrors: ApiGenericUserError[]; + }>; + loading: boolean; + error: Error | null; + reset: () => void; +} +``` + +The hook must declare freshness behavior for product list/detail screens. Use `refetchQueries` for the first API-backed integration unless cache shape is already stable. + +## Detailed Migration Phases + +### Phase 0: Baseline Verification And Plan Alignment + +1. Treat the current generated `admin/src/graphql/types.ts` as the source of truth unless a schema drift is found. +2. Verify that generated types include `ApiCatalogQuery`, `ApiProduct`, `ApiProductConnection`, `ApiVariant`, `ApiCategory`, `ApiTag`, `ApiProductSeo`, and `ApiInventoryItem`. +3. Verify that `ApiProduct` includes `media`, `categories`, `tags`, `features`, `seo`, `revision`, and `variantsCount`. +4. Verify that `ApiVariant` exposes inventory through `inventoryItem`. +5. Do not regenerate `admin/src/graphql/types.ts` unless this verification fails or the supergraph has intentionally changed. +6. If codegen is required, run it through the approved project workflow. Do not hand-edit generated files. + +Exit criteria: + +- The plan is aligned with the current generated schema. +- Product operations are confirmed to use `catalogQuery` and `catalogMutation`. +- Product media is confirmed to come from `Product.media` for product-level gallery/thumbnail display. + +### Phase 1: Create Product-Local GraphQL Module + +Files: + +- create `admin/src/domains/inventory/products/graphql/fragments.ts` +- create `admin/src/domains/inventory/products/graphql/queries.ts` +- create `admin/src/domains/inventory/products/graphql/mutations.ts` +- create `admin/src/domains/inventory/products/graphql/operation-types.ts` +- create `admin/src/domains/inventory/products/graphql/index.ts` +- update `admin/src/domains/inventory/graphql/*` as compatibility exports only + +Steps: + +1. Move or duplicate product fragments from `admin/src/domains/inventory/graphql/fragments.ts` into the product module. +2. Add `PRODUCTS_QUERY`. +3. Add `PRODUCT_DETAILS_QUERY`. +4. Move product mutations into product module files. +5. Keep non-product media fragments only if product mutations need them. +6. Build operation response/variable types from generated schema types without re-exporting generated types. +7. Update product hooks to import product operations from `products/graphql`. + +Exit criteria: + +- New product GraphQL documents live under `products/graphql`. +- `admin/src/domains/inventory/graphql` no longer owns product-specific operation definitions except compatibility re-exports. +- No product operation references `inventoryQuery.products` or `inventoryMutation.productCreate`. + +### Phase 2: Replace Mock-Backed Product List + +Files: + +- `admin/src/domains/inventory/products/hooks/use-products.ts` +- `admin/src/domains/inventory/products/page/page.tsx` +- `admin/src/shared/components/entity-picker-modal/configs/product-picker-config.ts` + +Steps: + +1. Replace the simulated delay and `mockProductsList` slicing in `useProducts` with Apollo `useQuery(PRODUCTS_QUERY)`. +2. Change hook options from page/pageSize to Relay cursor variables. +3. Return `products` from `connection.edges[].node`. +4. Return `totalCount`, `pageInfo`, `loading`, `error`, and `refetch`. +5. Update `ProductsPage` pagination state to store cursors and range metadata instead of page numbers. +6. Keep AG Grid rows as `ApiProduct`. +7. Keep product thumbnail from `getProductThumbnailFile(product)`, which reads `Product.media`. +8. Keep category and brand columns only if the list query selects the required `categories` and `features` fields. +9. Treat search, filters, and sort as disabled, client-only, or explicitly TODO until the backend exposes query args. +10. Replace the product picker direct `mockProductsList` import with the same API-backed hook or a picker-specific API-backed hook. +11. Keep picker-local `IPickableEntity` rows private to the picker config. +12. Stop generating fake bulk editor IDs from selected API products. Either pass selected `product.id` values through to a future API-backed bulk editor or explicitly keep bulk editor out of this phase. + +Exit criteria: + +- Product list page does not import `@/mocks/products`. +- Product picker config does not import `@/mocks/products`. +- Product list and picker are API-backed or use an explicitly isolated non-production mock boundary. +- Product list rows remain generated API-shaped product data, not a custom product row model. + +### Phase 3: Replace Mock-Backed Product Details + +Files: + +- `admin/src/domains/inventory/products/hooks/use-product.ts` +- `admin/src/domains/inventory/products/modals/product-modal/product-modal.tsx` +- `admin/src/domains/inventory/products/components/product-details-card/product-details-card.tsx` +- `admin/src/domains/inventory/products/components/product-details-card/types.ts` +- `admin/src/mocks/products/product-details.ts` + +Steps: + +1. Add `useProduct` backed by `PRODUCT_DETAILS_QUERY`. +2. Update `ProductModal` to fetch the product by `payload.entityId`. +3. Remove `findMockProductById` from `ProductModal`. +4. Replace `getMockVariantsTableData` with variants from the product details query or a dedicated variant query. +5. Keep `productDetailsMockData` only as supplemental data for data not yet exposed by `ApiProduct`. +6. Ensure `ProductDetailsCard` and all descendants read only fields selected by `ProductDetailsFields`. +7. Preserve existing `ProductDetailsCard` root contract as `product: ApiProduct` while the query selects the full data read by descendants. + +Exit criteria: + +- Details modal opens from a real product id. +- Product details root no longer receives a mock product. +- Supplemental mock data contains only non-product API data. +- No product details component reads fields missing from the product details query. + +### Phase 4: Harden Product Create Integration + +Files: + +- `admin/src/domains/inventory/products/hooks/use-create-product.ts` +- `admin/src/domains/inventory/products/mappers/product-create.mapper.ts` +- `admin/src/domains/inventory/products/mappers/product-errors.mapper.ts` +- `admin/src/domains/inventory/products/modals/create-product-modal/*` +- compatibility import sites for `prepareProductPayload` + +Steps: + +1. Move `prepareProductPayload` out of modal utilities into `products/mappers/product-create.mapper.ts`. +2. Keep form-only types such as `CreateProductInput` or `CreateProductFormValues` because they are not API output objects. +3. Keep current mapping for `title`, `handle`, `description`, `mediaFileIds`, `options`, and enabled `variants`. +4. Do not send fields the current create UI does not collect, such as status, categories, tags, pricing, or inventory. +5. Keep `mediaFileIds` as product media input; the current API exposes product media through `Product.media`. +6. Add a `product-errors.mapper.ts` for mapping `ApiGenericUserError` to form fields. +7. Add `reset` to `useCreateProduct`. +8. Add explicit list freshness behavior, initially through `refetchQueries` for `PRODUCTS_QUERY` or a caller-provided callback. + +Exit criteria: + +- Product create hook imports operations from `products/graphql`. +- Form-to-API mapping lives in `products/mappers`. +- API user errors are returned to the modal and can be displayed at the form boundary. +- Product list freshness after create is documented and implemented. + +### Phase 5: Convert Product Edit Flows To API Mutations + +Files: + +- `admin/src/domains/inventory/products/components/product-details-card/hooks/use-product-modals.ts` +- edit title, description, media, SEO, options, categories, tags, and variants modals +- product hooks and mutation documents added in Phase 1 + +Steps: + +1. Add focused mutation hooks for product title/handle, content, media, SEO, status, category/tag assignment, and variant updates as API contracts permit. +2. Replace `console.log` save handlers with mutation calls. +3. Use `catalogMutation.productUpdate` for product fields supported by `ApiProductUpdateInput`. +4. Use product media update input `media.fileIds` for product media ordering. +5. Use existing product option and variant media mutations only where the current schema supports the edit flow. +6. Use `inventoryMutation.inventoryItemUpdate` for inventory fields owned by inventory service when editing variant inventory data. +7. Keep modal-local row state inside modals; do not lift editable row view models into product details components. +8. Normalize `userErrors` in hooks or mappers. + +Exit criteria: + +- Edit flows no longer rely on `console.log` as their save implementation. +- Product update flows use generated API inputs. +- Product details refresh or cache updates are handled by hooks. +- Editable row models stay inside modal/editor boundaries. + +### Phase 6: Category, Tag, And Picker Cleanup + +Files: + +- `admin/src/mocks/products/categories.ts` +- `admin/src/shared/components/entity-picker-modal/configs/category-picker-config.ts` +- `admin/src/shared/components/entity-picker-modal/configs/product-picker-config.ts` +- category and tag edit modals + +Steps: + +1. Keep `mockCategories: ApiCategory[]` only as temporary picker mock data until category picker is API-backed. +2. Use generated category fields: `name`, `handle`, `isPublished`, `media`, `productsCount`, `parent`, `children`, and `ancestors`. +3. Do not create aliases such as `type ApiCategory = ICategory`. +4. Keep picker-local normalized rows private to picker configs. +5. Consume `product.categories` and `product.tags` directly in product UI. +6. Keep explicit primary category as a UI convention based on `product.categories[0]` until the API exposes first-class primary category semantics. + +Exit criteria: + +- Product category and tag UI consume generated API types. +- Shared product/category picker configs do not export product/category-specific view models. +- Any remaining category mock data is API-shaped. + +### Phase 7: Bulk Editor Boundary Decision + +Files: + +- `admin/src/domains/inventory/products/page/page.tsx` +- `admin/src/domains/inventory/products/modals/bulk-editor-modal/*` +- `admin/src/mocks/products/bulk-editor.ts` + +Steps: + +1. Decide whether the product API integration includes bulk editor. +2. If it is included, migrate bulk editor inputs from `IMockProduct` to generated API-shaped products/variants and API-backed update workflows. +3. If it is excluded, isolate it clearly as a demo/mock flow and stop mapping selected `ApiProduct` rows to fake `prod-*` ids in the product page. +4. Keep bulk editor local row models private to the bulk editor boundary. + +Exit criteria: + +- Product page does not silently translate real selected product ids into unrelated mock ids. +- Bulk editor is either API-backed or explicitly outside the acceptance criteria for product API integration. + +### Phase 8: Remove Obsolete Product Mock Contracts From Migrated Surfaces + +Files: + +- `admin/src/mocks/products/types.ts` +- `admin/src/mocks/products/index.ts` +- remaining imports under migrated product UI and shared picker files + +Steps: + +1. Remove legacy product mock imports only from migrated product UI surfaces. +2. Do not delete `admin/src/mocks/products/types.ts` while unrelated bundle or legacy mocks still import it. +3. Remove `IProductListItem` if it is no longer used anywhere. +4. Keep generated API-shaped mock builders if they remain useful for Storybook, local fixtures, or non-API development. + +Exit criteria: + +- API-backed product hooks/pages/modals do not import product mocks. +- Legacy mock types are absent from migrated product UI files. +- Remaining legacy mock types are clearly outside the migrated product API surface. + +## File Impact Summary + +### New Or Converged Product API Files + +- `admin/src/domains/inventory/products/graphql/fragments.ts` +- `admin/src/domains/inventory/products/graphql/queries.ts` +- `admin/src/domains/inventory/products/graphql/mutations.ts` +- `admin/src/domains/inventory/products/graphql/operation-types.ts` +- `admin/src/domains/inventory/products/graphql/index.ts` +- `admin/src/domains/inventory/products/hooks/use-product.ts` +- `admin/src/domains/inventory/products/hooks/use-update-product.ts` +- `admin/src/domains/inventory/products/hooks/use-delete-product.ts` +- `admin/src/domains/inventory/products/mappers/product-create.mapper.ts` +- `admin/src/domains/inventory/products/mappers/product-errors.mapper.ts` + +### Must Change For API-Backed List And Details + +- `admin/src/domains/inventory/products/hooks/use-products.ts` +- `admin/src/domains/inventory/products/hooks/use-create-product.ts` +- `admin/src/domains/inventory/products/page/page.tsx` +- `admin/src/shared/components/entity-picker-modal/configs/product-picker-config.ts` +- `admin/src/domains/inventory/products/modals/product-modal/product-modal.tsx` +- `admin/src/domains/inventory/products/components/product-details-card/product-details-card.tsx` +- `admin/src/domains/inventory/products/components/product-details-card/types.ts` +- `admin/src/domains/inventory/graphql/fragments.ts` +- `admin/src/domains/inventory/graphql/mutations.ts` +- `admin/src/domains/inventory/graphql/index.ts` + +### Already Mostly Aligned + +- `admin/src/mocks/products/api-builders.ts` +- `admin/src/mocks/products/data.ts` +- `admin/src/mocks/products/products-list.ts` +- `admin/src/mocks/products/categories.ts` +- `admin/src/domains/inventory/products/utils/api-product-display.ts` +- `admin/src/domains/inventory/products/utils/product-status.ts` +- `admin/src/domains/inventory/products/utils/product-measurements.ts` +- `admin/src/domains/inventory/products/components/product-info-header/*` +- `admin/src/domains/inventory/products/components/product-content-tabs/*` +- `admin/src/domains/inventory/products/components/product-details-card/sections/*` +- `admin/src/domains/inventory/products/components/variants/edit-variants-modal.tsx` + +These files may still need small query-field alignment or save-flow updates, but they no longer need a full legacy contract migration. + +### Should Not Change In This Migration + +- changeset files +- unrelated backend service files +- generated `admin/src/graphql/types.ts` except through approved codegen +- unrelated bundle mock contracts unless Phase 7 explicitly includes bulk editor or bundle surfaces + +## Verification Plan + +Project instructions say not to run tests or `tsc` for verification. + +Allowed verification: + +1. Run build when code changes are implemented and a new code version needs validation. +2. Use targeted static searches. +3. Inspect product screens manually after build if a dev server is already running or started through the approved project workflow. + +Recommended static checks: + +```text +rg "inventoryQuery[^{]*\\{|inventoryMutation[^{]*\\{" admin/src/domains/inventory/products admin/src/domains/inventory/graphql +rg "mockProductsList|findMockProductById|getMockVariantsTableData" admin/src/domains/inventory/products admin/src/shared/components/entity-picker-modal/configs/product-picker-config.ts +rg "@/mocks/products" admin/src/domains/inventory/products/hooks admin/src/domains/inventory/products/page admin/src/domains/inventory/products/modals/product-modal admin/src/shared/components/entity-picker-modal/configs/product-picker-config.ts +rg "ProductListItem|IProductListItem|toProductListItem|toProductDetailsView|mapMockProductToApi|mapApiProductToMockProduct" admin/src/domains/inventory/products admin/src/shared/components/entity-picker-modal admin/src/mocks/products +rg "from [\"']@/mocks/products/types[\"']|from [\"']\\.\\/types[\"']|from [\"']\\.\\.\\/types[\"']" admin/src/domains/inventory/products admin/src/shared/components/entity-picker-modal +rg "variant\\.(sku|stock|weight|dimensions|cost|inStock)" admin/src/domains/inventory/products admin/src/mocks/products --glob '!admin/src/domains/inventory/products/modals/bulk-editor-modal/**' +rg "type ApiCategory = ICategory|type ApiInventoryItem =|interface ApiCategory|interface ApiInventoryItem" admin/src +rg "\\.toLocaleDateString|\\.toLocaleTimeString" admin/src/domains/inventory/products +``` + +Notes: + +- Do not use broad `rg "IProduct"` checks because local interface names such as `IProductInfoHeaderProps` are not legacy mock contracts. +- Do not flag `WeightUnit` or `DimensionUnit` usage by itself. The current generated inventory weight/dimension types include `displayUnit`, so these enums are valid when used with generated API fields. +- Numeric `.toLocaleString()` is acceptable for display formatting. The unsafe pattern is calling date methods directly on API date scalar strings. + +## Acceptance Criteria + +- Product list is backed by `catalogQuery.products`. +- Product details modal is backed by `catalogQuery.product`. +- Product create uses `catalogMutation.productCreate` through module-local product GraphQL documents. +- Product update/edit flows use generated API inputs and expose `userErrors`. +- Product list, picker, and details consume generated API-shaped data directly. +- Product media display uses `Product.media`. +- Variant UI uses `ApiVariant` and reads inventory through `variant.inventoryItem`. +- Category and tag UI use generated `ApiCategory` and `ApiTag`. +- No product output view model is introduced for API-backed screens. +- API-backed product hooks/pages/modals do not import product mocks. +- Legacy product mock types remain only in explicitly excluded legacy/mock islands. +- Build passes when run according to project rules. + +## Open Decisions + +1. Should product search/filter/sort be added to `CatalogQuery.products`, or should the first API-backed list ship with pagination only? +2. Should product details fetch all variants up front or use a dedicated paginated variant query for the variants table? +3. Should the product picker share `useProducts` or use a smaller picker-specific `ProductsPicker` query? +4. Is bulk editor part of this product API integration scope, or should it remain a separate mock/demo workflow for now? +5. Does the API need first-class primary category semantics, or is `product.categories[0]` acceptable as a temporary UI convention? diff --git a/admin/docs/product-seo-plan.md b/admin/docs/product-seo-plan.md index 71cd11076..c6032c331 100644 --- a/admin/docs/product-seo-plan.md +++ b/admin/docs/product-seo-plan.md @@ -54,7 +54,7 @@ import { product } from "./products"; export const productSeo = inventorySchema.table( "product_seo", { - projectId: uuid("project_id").notNull(), + storeId: uuid("store_id").notNull(), productId: uuid("product_id") .notNull() .references(() => product.id, { onDelete: "cascade" }), @@ -71,8 +71,8 @@ export const productSeo = inventorySchema.table( }, (table) => [ primaryKey({ columns: [table.productId, table.locale] }), - index("idx_product_seo_project").on(table.projectId), - index("idx_product_seo_project_locale").on(table.projectId, table.locale), + index("idx_product_seo_store").on(table.storeId), + index("idx_product_seo_store_locale").on(table.storeId, table.locale), ] ); @@ -95,7 +95,7 @@ export type NewProductSeo = typeof productSeo.$inferInsert; ```sql -- Create product_seo table CREATE TABLE inventory.product_seo ( - project_id UUID NOT NULL, + store_id UUID NOT NULL, product_id UUID NOT NULL REFERENCES inventory.product(id) ON DELETE CASCADE, locale VARCHAR(8) NOT NULL, @@ -111,12 +111,12 @@ CREATE TABLE inventory.product_seo ( PRIMARY KEY (product_id, locale) ); -CREATE INDEX idx_product_seo_project ON inventory.product_seo(project_id); -CREATE INDEX idx_product_seo_project_locale ON inventory.product_seo(project_id, locale); +CREATE INDEX idx_product_seo_store ON inventory.product_seo(store_id); +CREATE INDEX idx_product_seo_store_locale ON inventory.product_seo(store_id, locale); -- Migrate existing SEO data from product_translation -INSERT INTO inventory.product_seo (project_id, product_id, locale, seo_title, seo_description) -SELECT project_id, product_id, locale, seo_title, seo_description +INSERT INTO inventory.product_seo (store_id, product_id, locale, seo_title, seo_description) +SELECT store_id, product_id, locale, seo_title, seo_description FROM inventory.product_translation WHERE seo_title IS NOT NULL OR seo_description IS NOT NULL; diff --git a/admin/docs/product-variant-pricing-update-plan.md b/admin/docs/product-variant-pricing-update-plan.md new file mode 100644 index 000000000..a4f024229 --- /dev/null +++ b/admin/docs/product-variant-pricing-update-plan.md @@ -0,0 +1,216 @@ +# Product Variant Pricing Update Integration Plan + +## Goal + +Wire the product details Pricing menu to the real Admin GraphQL API for product variant price updates. + +The current Pricing card already exposes an `Edit Prices` menu item, but the item is not connected to a save flow. The implementation must persist edited prices for multiple variants of the same product through `catalogMutation.productUpdate`, using `ProductUpdateInput.variants[].pricing`. + +## Current Baseline + +- `ProductDetailsCard` renders `PricingBlock` for the product details modal. +- `PricingBlock` owns the pricing header, selected variant state, current price, price history, and KPI display. +- The Pricing header menu already contains `Edit Prices` and `View History`. +- `EditVariantsModal` already supports a pricing-only mode through `availableColumns`. +- Product variant editor rows use `price` and `compareAtPrice` in minor units, matching `VariantPricingOpInput.amountMinor` and `VariantPricingOpInput.compareAtMinor`. +- Admin generated GraphQL types currently map the `BigInt` scalar to `number` for both input and output values. +- The product module already has `PRODUCT_UPDATE_MUTATION` and `useUpdateProduct` for `catalogMutation.productUpdate`. +- `ProductUpdateInput` supports variant updates through `variants: [VariantUpdateInput!]`. +- The backend `productUpdate` resolver maps `operations.variants[].pricing` into variant update workflow operations and decodes GraphQL global `Variant.id` values before running scripts. +- `EditVariantsModal` currently closes immediately after `onSave`; this must be changed so API user errors can keep the modal open. +- `usePricingWidget` currently imports `./mocks`; the pricing widget must be moved to the real GraphQL API before refresh behavior can be relied on. +- `ProductModal` currently fetches a paginated product variant connection. The pricing edit flow must explicitly decide whether it has all variants or must load the remaining pages before opening the editor. + +## API Contract + +Use the existing product update mutation from the Admin frontend: + +```graphql +mutation ProductUpdate( + $productId: ID! + $operations: ProductUpdateInput + $expectedRevision: Int +) { + catalogMutation { + productUpdate( + productId: $productId + operations: $operations + expectedRevision: $expectedRevision + ) { + product { + ...ProductMutationResultFields + } + operationResults { + applied + type + errors { + ...UserErrorFields + } + } + userErrors { + ...UserErrorFields + } + } + } +} +``` + +For one product save, send one mutation call. Each changed row maps to one `VariantUpdateInput` item. Because the current Admin codegen maps `BigInt` inputs to `number`, build the typed `ApiProductUpdateInput` with numeric minor-unit values. Do not cast these values to strings unless the codegen scalar mapping is intentionally changed first. + +```ts +import { CurrencyCode, type ApiProductUpdateInput } from "@/graphql/types"; + +const operations: ApiProductUpdateInput = { + variants: changedRows.map((row) => { + const originalVariant = variantsById.get(row.id); + + return { + variantId: row.id, + pricing: { + currency: originalVariant?.price?.currency ?? CurrencyCode.Usd, + amountMinor: row.price, + compareAtMinor: row.compareAtPrice, + }, + }; + }), +}; + +{ + productId: product.id, + expectedRevision: product.revision, + operations, +} +``` + +Rules: + +- Use `product.id` as `productId`. +- Use `product.revision` as `expectedRevision`. +- Include only rows whose `price` or `compareAtPrice` changed. +- Send changed rows together in `operations.variants`; do not call `variantUpdatePricing` once per row. +- `variantId` is the API `Variant.id` from the edited row. +- `currency` is the current `variant.price.currency`, falling back to `CurrencyCode.Usd` when the variant has no current price. +- `amountMinor` is the edited `price` in minor units. +- `compareAtMinor` is the edited `compareAtPrice` in minor units, or `null` when cleared. + +## Variant Source And Pagination + +The pricing edit flow must use API-backed product variants, not `usePricingWidget` mock variants. + +The product details query already returns `product.revision`, `product.options`, and a paginated `product.variants` connection. For the pricing editor, use one of these approaches and make the choice explicit in implementation: + +1. Preferred first implementation: before opening `EditVariantsModal`, ensure every variant for the product is available by loading nested `product.variants(first, after)` pages until `pageInfo.hasNextPage` is false. Use a focused product variants query if the current product details query page size is not enough. +2. If the UI intentionally edits only the currently loaded page, label and scope the flow accordingly. Do not describe the save as "multiple variants of the same product" without clarifying the page boundary. + +For this plan, use the preferred first implementation. The editor should receive all current product variants so unchanged rows can be excluded reliably and users do not silently miss variants beyond the first product details page. + +## Pricing Widget Query + +Replace the `./mocks` path with a real query against the current Admin schema: + +```graphql +query ProductPricingWidget($input: PricingWidgetInput!) { + widgetQuery { + pricing(input: $input) { + currentPrice { + id + amountMinor + compareAtMinor + currency + effectiveFrom + effectiveTo + isCurrent + recordedAt + } + currentCostPrice { + id + unitCostMinor + currency + effectiveFrom + effectiveTo + isCurrent + recordedAt + } + history { + edges { + cursor + node { + id + amountMinor + compareAtMinor + currency + effectiveFrom + effectiveTo + isCurrent + recordedAt + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + statistics { + minPriceMinor + maxPriceMinor + avgPriceMinor + currency + } + } + } +} +``` + +Rules: + +- Use `widgetQuery.pricing(input:)`. +- Derive `input.variantId` from the selected API variant ID. +- Derive `input.currency` from the selected variant price currency, falling back to `CurrencyCode.Usd`. +- Convert the selected period into `from` and `to` `DateTime` strings at the hook boundary. +- Return a `refetch` function from the pricing widget hook so the save handler can refresh the widget after mutation success. + +## Implementation Steps + +1. Reuse `PRODUCT_UPDATE_MUTATION` and `useUpdateProduct`; do not add a `variantUpdatePricing` GraphQL document or hook for this flow. +2. Replace `usePricingWidget` mock fetching with the real `ProductPricingWidget` GraphQL query and return a widget `refetch` function. +3. Pass enough product context into the Pricing edit flow to build the mutation input: `product.id`, `product.revision`, current product variants, product options, and a product details refresh callback. +4. Ensure the Pricing edit flow has all product variants before opening `EditVariantsModal`. Add a focused product variants loader if the current product details variant page is incomplete. +5. Open `EditVariantsModal` from the Pricing header `Edit Prices` menu with pricing columns only: + - `availableColumns: ["price", "compareAtPrice"]` + - `showColumnSettings: false` +6. Update `IEditVariantsModalPayload["onSave"]` and `EditVariantsModal` save handling so `onSave` may return `boolean | void | Promise`: + - any result except `false` resets edits and closes the modal; + - return `false` to keep the modal open; + - keep a local `submitting` state while an async save is in progress, matching the existing edit title/media/SEO modal pattern. +7. On save, compare edited rows with the current product variants and build `operations.variants` only for rows where `price` or `compareAtPrice` changed. +8. If no rows changed, show a neutral notification and close the modal without calling `updateProduct`. +9. Call `updateProduct` once: + - `productId: product.id` + - `expectedRevision: product.revision` + - `operations: { variants: changedVariantPricingInputs }` +10. Add a small product update error normalizer used by the pricing save handler: + - include top-level `userErrors`; + - include all `operationResults[].errors`; + - treat any `operationResults[]` item with `applied === false` as a failure even if the errors array is empty. +11. Show success/error notifications at the component boundary that owns the save handler. Display the first useful normalized error message and keep the modal open on failure. +12. After a successful save, refresh product details and the Pricing widget so current price, variant row prices, history, and statistics reflect persisted data. + +## Verification + +Do not run `test` or `tsc` directly. Use the Shopana CLI build command when verification is needed. + +Expected manual behavior: + +- `Pricing -> Edit Prices` opens the variant editor. +- The editor shows only pricing fields. +- Products with more variants than the product details page size either load all variants into the pricing editor or clearly scope the editor to the loaded page. For this implementation, all variants should be loaded. +- Saving changed prices calls `catalogMutation.productUpdate` once for the product. +- The request contains all changed variant pricing rows under `operations.variants`. +- The request uses numeric `amountMinor` and `compareAtMinor` values unless the Admin `BigInt` scalar mapping has been changed. +- Unchanged rows are not included in the request. +- No mutation is sent when there are no changed pricing rows. +- Top-level user errors, operation result errors, and unapplied operation results are displayed and keep the modal open. +- A successful save closes the modal, refreshes product details, and refreshes Pricing card data from `widgetQuery.pricing`. diff --git a/admin/docs/product-variants-editor-read-path-integration-plan.md b/admin/docs/product-variants-editor-read-path-integration-plan.md new file mode 100644 index 000000000..21d4d2e67 --- /dev/null +++ b/admin/docs/product-variants-editor-read-path-integration-plan.md @@ -0,0 +1,305 @@ +# Product Variants Editor Read Path Integration Plan + +## Цель + +Интегрировать read path для variants в Admin UI product details: + +- в `ProductDetailsCard -> VariantsTableSection` корректно показывать variants продукта из Admin GraphQL API с cursor pagination; +- при открытии `Edit variants` из variants section загружать полный список variants продукта; +- в `EditVariantsModal` корректно отображать все AG Grid колонки, которые уже поддерживает variants editor, на основе данных из API; +- не реализовывать save/write path в рамках этого плана. + +## Scope + +В scope входит только чтение и отображение: + +- GraphQL fragments/query shape для `Product.variants`; +- page state для variants section; +- загрузчик всех variants перед открытием editor modal; +- API-to-editor mapping для AG Grid rows; +- loading/error/empty states для read flow; +- ручная проверка отображения. + +Вне scope: + +- `catalogMutation.productUpdate`; +- `variantCreate`, `variantDelete`, pricing/inventory/media mutations; +- bulk edit persistence; +- backend schema changes, если текущий Admin schema уже возвращает поля из `VariantFields`; +- changeset. + +## Архитектурные правила + +- GraphQL operations остаются в `admin/src/domains/inventory/products/graphql`. +- Hooks владеют `useQuery`, `client.query`, loading/error/refetch и не отдают наружу raw operation nesting. +- Components получают generated API types напрямую из `@/graphql/types`. +- Не добавлять API-output view models. UI-local row model допустим только внутри variants editor (`IVariantEditorInput`, `IVariantEditorRow`). +- Для денег использовать default currency из workspace/Admin context. Не брать currency рядом с price как источник отображаемой валюты для стандартного Admin UI. В рамках этого плана это касается и HTML variants table, и AG Grid price/cost columns. +- Не запускать `test` и `tsc`. Если нужна проверка сборки, использовать build через project tooling. + +## Текущий baseline + +- `ProductModal` вызывает `useProduct({ variantsFirst: 10, variantsAfter })` и хранит cursor history для variants pagination. +- `PRODUCT_DETAILS_QUERY` уже принимает `$variantsFirst` и `$variantsAfter`. +- `PRODUCT_DETAILS_FRAGMENT` уже запрашивает `variants(first: $variantsFirst, after: $variantsAfter)`. +- `VARIANT_FRAGMENT` уже содержит данные для основных read columns: + - identity/title/handle/default timestamps; + - selected options; + - media; + - current price; + - `inventoryItem`, stock, weight, dimensions, unit cost. +- `VariantsTableSection` показывает текущую страницу variants в HTML table и имеет prev/next controls. +- `EditVariantsModal` уже использует `mapApiVariantsToEditorInputs` и `VariantsEditorGrid`. +- `useProductVariantsLoader` уже умеет загрузить все pages через `PRODUCT_VARIANTS_QUERY`. +- `useProductModals.editVariants` сейчас передаёт в modal только `getProductVariants(product)`, то есть только текущую страницу product details. + +## API Read Contract + +Использовать существующие frontend operations: + +```graphql +query ProductDetails($id: ID!, $variantsFirst: Int, $variantsAfter: String) { + catalogQuery { + product(id: $id) { + ...ProductDetailsFields + } + } +} +``` + +```graphql +query ProductVariants($id: ID!, $first: Int, $after: String) { + catalogQuery { + product(id: $id) { + id + variants(first: $first, after: $after) { + edges { + cursor + node { + ...VariantFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } +} +``` + +`VariantFields` должен оставаться единым источником данных для variants table и variants editor. Если AG Grid column не получает данные, сначала расширять `VariantFields`, а не создавать отдельный mock/adapter contract. + +## Variants Section Read Path + +1. Оставить `ProductModal` владельцем pagination state: + - `variantsPageIndex`; + - `variantCursorHistory`; + - текущий `variantsAfter`; + - `handleVariantsPageChange`. + +2. Убедиться, что при смене `entityId` pagination сбрасывается в первую страницу: + - `setVariantsPageIndex(0)`; + - `setVariantCursorHistory([null])`. + +3. Передавать в `ProductDetailsCard` только API-backed table data: + - `variants: product.variants.edges.map(edge => edge.node)`; + - `pageInfo: product.variants.pageInfo`; + - `totalCount: product.variants.totalCount`. + +4. В `VariantsTableSection` отображать: + - текущую страницу variants; + - `totalCount`; + - disabled prev/next по `pageInfo.hasPreviousPage` и `pageInfo.hasNextPage`; + - empty state, если `variantsCount > 0`, но текущая страница пустая из-за некорректного cursor/page state. + + Готовое решение: + - `ProductDetailsCard` не должен скрывать весь variants section только потому, что текущая page пустая. Render condition: `variantsTableData && (variantsTableData.totalCount > 0 || product.variantsCount > 0)`. + - `VariantsTableSection` больше не возвращает `null`, если `variants.length === 0 && totalCount > 0`. Вместо этого он рендерит `Paper` header, total count, prev/next controls и empty state внутри body. + - Если `totalCount === 0 && product.variantsCount === 0`, variants section можно не показывать. + - Gate по `product.options.length > 0` убрать из условия показа section. Options нужны только для option labels/columns; отсутствие options не должно скрывать API-backed variants list. + +5. Не смешивать данные variants section с `productDetailsMockData`. Для variants использовать только `ApiVariant[]`. + +6. На page change не сбрасывать весь product modal в misleading empty state. Если текущий UX со `cache-and-network` показывает skeleton на каждой странице, отдельно решить, оставляем ли full skeleton или добавляем section-level loading state. Для первого read-path шага достаточно сохранить корректность данных и навигации. + + Готовое решение: + - `ProductModal` показывает full skeleton только когда `loading && !product`. + - Если `loading && product`, оставлять текущий product details на экране и передавать `isVariantsPageLoading` в `ProductDetailsCard -> VariantsTableSection`. + - В `VariantsTableSection` во время page fetch disable prev/next и показывать loading на pagination/action area. Таблица может показывать previous page data до прихода новой page, потому `useProduct` уже использует `previousData`. + - `handleVariantsPageChange` должен игнорировать повторный click, пока `isVariantsPageLoading === true`. + +7. Для price rendering в variants section: + - `ProductDetailsCard` получает `defaultCurrency` через `useDefaultCurrency()`. + - `VariantsTableSection` принимает `defaultCurrency: CurrencyCode | null`. + - `VariantRow` форматирует `variant.price.amountMinor` и `variant.price.compareAtMinor` только через `defaultCurrency`; если `defaultCurrency` или amount отсутствует, показывает fallback `—`. + - Не использовать `useVariantPrice(variant.price)` и не передавать `variant.price.currency` в `formatPrice` внутри этого read path. + +## Edit Variants Modal Read Path + +1. Изменить открытие variants editor в `useProductModals.editVariants`: + - использовать `useProductVariantsLoader`; + - перед `openEditVariantsModal` вызвать `loadAllProductVariants(product)`; + - в payload передавать полный `ApiVariant[]`, а не только текущую страницу. + +2. Пока идёт загрузка всех variants: + - заблокировать повторное открытие editor; + - показать user-visible loading feedback на action/menu button или через message/loading state; + - при ошибке показать message error и не открывать modal. + + Готовое решение: + - `useProductModals` подключает `useProductVariantsLoader` и возвращает `isEditVariantsLoading`. + - `handleEditVariants` становится `async`; если `isEditVariantsLoading`, он сразу возвращает управление. + - `ProductDetailsCard` прокидывает `modals.isEditVariantsLoading` в `VariantsTableSection`. + - `VariantsTableSection` принимает `isEditLoading?: boolean` и передает его в action. + - `EditAction` расширить props `loading?: boolean` и `disabled?: boolean`; эти props должны disable menu item и button, а `loading` должен быть виден на button. + - В `catch` показывать `message.error(err instanceof Error ? err.message : "Product variants could not be loaded")`; modal не открывать. + +3. В payload modal передавать: + - `productId: product.id`; + - `variants: allLoadedVariants`; + - `productOptions: product.options`; + - `defaultCurrency` из workspace context. + + Готовое решение: + - `ProductDetailsCard` получает `defaultCurrency` через `useDefaultCurrency()` и передает его в `useProductModals(product, { onProductRefresh, defaultCurrency })`. + - `useProductModals.editVariants` передает этот `defaultCurrency` в `openEditVariantsModal`. + - Fallback `typedPayload.defaultCurrency ?? useDefaultCurrency() ?? null` в `EditVariantsModal` оставить как защиту для других callers. + +4. `onSave` оставить read-only stub: + - вернуть `false`; + - показать сообщение, что save/write path не входит в текущую интеграцию. + +5. Не добавлять restricted `availableColumns` для общего variants editor. В отличие от pricing/inventory modal modes, editor из variants section должен иметь доступ ко всем колонкам. + +## AG Grid Column Mapping + +`mapApiVariantToEditorInput` должен покрывать все fields из `IVariantEditorInput`: + +- display: + - `id <- variant.id`; + - `title <- variant.title ?? variant.handle`; + - `imageUrl <- first variant media file url` после сортировки `variant.media` по `sortIndex`; + - `media <- variant.media[].file.url` после сортировки `variant.media` по `sortIndex`; +- options: + - `options <- variant.selectedOptions` через `productOptions`; + - dynamic option columns строить из `productOptions`, а не только из реально выбранных values в rows, чтобы все product options из API были представлены в editor. +- inventory: + - `sku <- variant.inventoryItem?.sku`; + - `barcode <- null`, пока API не возвращает barcode; + - если `variantEditorScope.warehouseId` задан: + - `onHand <- warehouseStock.quantityOnHand`; + - `reserved <- warehouseStock.reservedQuantity`; + - `unavailable <- warehouseStock.unavailableQuantity`; + - если warehouse scope не задан: + - `onHand <- sum(inventoryItem.stock[].quantityOnHand)`; + - `reserved <- sum(inventoryItem.stock[].reservedQuantity)`; + - `unavailable <- sum(inventoryItem.stock[].unavailableQuantity)`; + - если `inventoryItem.stock` пустой, fallback: `onHand <- inventoryItem.totalAvailable`, `reserved <- 0`, `unavailable <- 0`; + - `available` считается в `VariantsEditorGrid`; +- pricing: + - `price <- variant.price?.amountMinor`; + - `compareAtPrice <- variant.price?.compareAtMinor`; + - `costPrice <- variant.inventoryItem?.unitCost?.amountMinor`; +- shipping/attributes: + - `weight`, `weightUnit` через `mapApiWeightToVariantFields`; + - `length`, `width`, `height`, `dimensionUnit` через `mapApiDimensionsToVariantFields`. + +Для общей variants modal inventory quantity columns являются aggregate across warehouses, рассчитанными из `inventoryItem.stock`. Для inventory-specific modal остается warehouse-specific режим через `variantEditorScope.warehouseId`. + +## Column Availability + +Для открытия из variants section ожидаемый AG Grid набор: + +- pinned display column: `Title`; +- media: `Variant Media`; +- dynamic option columns from API product options; +- pricing: `Price`, `Compare at`, `Cost`; +- inventory: `SKU`, `Barcode`, `On hand`, `Unavailable`, `Reserved`, `Available`; +- attributes/shipping: `Weight`, `Length`, `Width`, `Height`. + +Колонки, скрытые пользовательскими настройками, должны оставаться доступными через `VariantsColumnSettings`. Если требование именно "показать все колонки сразу", нужно в этом read-path шаге явно изменить default visibility или добавить режим `showAllColumnsByDefault` для открытия из variants section. Не использовать `availableColumns`, потому что он ограничивает набор колонок. + +Готовое решение для этого плана: + +- Не менять persisted default visibility глобально. +- Не передавать `availableColumns` при открытии из variants section. +- Все supported columns должны быть доступны через `VariantsColumnSettings`. +- Если нужно показать все columns сразу именно для variants section, добавить optional payload flag `showAllColumnsByDefault?: boolean` и обработать его в `EditVariantsModal -> VariantsEditorGrid -> useVariantsColumns` без изменения persisted user settings. Этот flag не нужен для минимальной read-path интеграции, если acceptance допускает включение скрытых columns через settings. + +## Implementation Steps + +1. Проверить `VariantFields` против списка AG Grid columns и добавить недостающие read fields только в `admin/src/domains/inventory/products/graphql/fragments.ts`. +2. Проверить `ProductDetailsQueryVariables` и `ProductVariantsQueryVariables`, чтобы pagination variables соответствовали API и не вводили UI-only names. +3. Оставить `ProductModal` source of truth для paginated variants section и реализовать section-level loading: + - full skeleton только при `loading && !product`; + - `isVariantsPageLoading = loading && !!product`; + - disable pagination clicks while loading. +4. Обновить `useProductModals`: + - подключить `useProductVariantsLoader`; + - загружать все variants перед `openEditVariantsModal`; + - передавать полный массив variants в modal. +5. Прокинуть loading state из `useProductModals` в `ProductDetailsCard`/`VariantsTableSection`, чтобы `Edit variants` action был disabled/loading. +6. Расширить `EditAction` props: + - `loading?: boolean`; + - `disabled?: boolean`; + - disabled должен применяться и к dropdown menu item, и к button. +7. Проверить `mapApiVariantToEditorInput`: + - убрать любые mock-only assumptions; + - оставить API-shaped source objects; + - не создавать новый output view model. +8. Обновить stock mapping в `mapApiVariantToEditorInput`: + - warehouse scope использует stock конкретного warehouse; + - общий scope агрегирует `inventoryItem.stock`; + - fallback на `inventoryItem.totalAvailable` только если stock rows отсутствуют. +9. Обновить options source для AG Grid: + - `optionGroups` строить из `productOptions`; + - если `productOptions` пустой, fallback на `variantInputs` допустим для backward compatibility. +10. Проверить `VariantsEditorGrid`: + - dynamic option columns строятся из API selected options/product options; + - price headers используют default currency; + - row data обновляется при новом `variants` payload; + - убрать `useState(initialRows)` без setter или синхронизировать rows через `useEffect`; предпочтительно заменить на derived `const rows = initialRows`, потому edits уже живут в store. +11. Обновить variants section price formatting: + - `ProductDetailsCard` получает `defaultCurrency`; + - `VariantsTableSection`/`VariantRow` используют `defaultCurrency`; + - не использовать `variant.price.currency`. +12. Проверить empty/fallback display: + - no media; + - no price; + - no inventory item; + - no unit cost; + - no weight/dimensions; + - unknown option value. +13. Не трогать save flow, кроме read-only stub сообщения при попытке сохранить. + +## Verification + +Не запускать `test` и `tsc`. + +Ручная проверка: + +- открыть product details для продукта с variants; +- variants section показывает первую страницу API variants и корректный `totalCount`; +- `Next` загружает следующую страницу, `Prev` возвращает предыдущую; +- disabled states соответствуют `pageInfo`; +- при page change product modal не превращается в full skeleton, pagination controls показывают loading/disabled; +- при cursor/page mismatch variants section показывает empty state, а не пропадает; +- строки показывают title/options, media, price, SKU/stock, weight/dimensions из API; +- price/compare-at в variants section отображаются с default currency из workspace context, а не с `variant.price.currency`; +- `Edit variants` открывает modal после загрузки всех variants, а не только текущей страницы; +- повторный click по `Edit variants` во время загрузки заблокирован; +- ошибка загрузки всех variants показывает message error и не открывает modal; +- AG Grid содержит строки для всех variants продукта; +- option columns соответствуют product options из API; +- повторное открытие modal для другого product/variants payload показывает новые rows, а не старый `useState(initialRows)` snapshot; +- pricing/inventory/attribute columns отображают значения из API или корректный fallback; +- general variants modal показывает aggregate stock values из `inventoryItem.stock`; inventory modal со scope продолжает показывать stock выбранного warehouse; +- пользовательские column settings позволяют включить скрытые columns; +- Save в modal не делает mutation и остаётся read-only. + +Если нужна машинная проверка после реализации, запускать только build через project tooling, без `test`/`tsc`. diff --git a/admin/docs/product-variants-editor-write-path-integration-plan.md b/admin/docs/product-variants-editor-write-path-integration-plan.md new file mode 100644 index 000000000..13f62f2fe --- /dev/null +++ b/admin/docs/product-variants-editor-write-path-integration-plan.md @@ -0,0 +1,402 @@ +# Product Variants Editor Write Path Integration Plan + +## Контекст + +`product-variants-editor-read-path-integration-plan.md` уже реализован: variants section читает API-backed variants, перед открытием `Edit variants` загружает все variants продукта и передаёт их в общий `EditVariantsModal`. + +Следующий шаг - убрать read-only save stub при открытии модалки из variants section и подключить реальное сохранение всех API-backed полей, которые пользователь может вводить прямо в таблице. Pricing и inventory уже подключены в отдельных restricted modal flows; общий variants editor должен переиспользовать их API contracts/mappers, а не оставаться physical-only. + +## Цель + +Интегрировать API-backed save для общего `Edit variants` entry point из `ProductDetailsCard -> VariantsTableSection`: + +- `Edit variants` открывает тот же `EditVariantsModal`, но больше не показывает read-only save message; +- все поля, которые можно изменить прямо в таблице и которые имеют текущий API-backed write contract, сохраняются по одному `Save`; +- картинки/media остаются вне scope этого шага; +- read-only/calculated fields не становятся draft edits; +- поля без API-backed write contract не должны быть editable, иначе появится illusion of save; +- после успешного сохранения product details, variants read path и затронутые widgets обновляются. + +## Scope + +В scope общего variants editor write path входят все API-backed editable table fields: + +### Pricing-owned fields + +- `price`; +- `compareAtPrice`. + +Сохранять через существующий Catalog product update path и `prepareChangedVariantPricingInputs`. + +### Inventory-owned item fields + +- `sku`; +- `costPrice`; +- `weight`; +- `length`; +- `width`; +- `height`. + +Сохранять через `inventoryMutation.inventoryItemUpdate`. + +### Inventory-owned stock fields + +- `onHand`; +- `unavailable`. + +Сохранять через `inventoryMutation.inventoryItemUpdate.stock` для явно выбранного warehouse scope. Для общего variants editor использовать default warehouse, как это уже делает Inventory widget edit flow. + +### Read-only table fields + +Эти поля могут быть видимыми, но не являются direct table inputs и не должны попадать в draft edits: + +- `reserved` - managed by order/reservation system; +- `available` - calculated; +- dynamic option columns - сейчас read-only, option value editing требует picker UX и options contract before making them editable; +- `title` / title column - display-only в текущем grid. + +### Out of scope + +- картинки/media reorder/upload - текущая grid media column остаётся read-only; для save нужен отдельный media editor flow через `variantUpdateMedia`. + +### Unsupported barcode field + +`barcode` сейчас есть в UI config как editable placeholder, но текущая Admin schema не возвращает и не обновляет barcode. Это поле не поддерживается текущим API contract и должно быть полностью убрано из общего variants editor. + +Implementation must remove `barcode` from: + +- variants editor column config; +- default column visibility; +- selectable columns; +- editor row/input mapping for the API-backed variants editor, where possible without breaking unrelated legacy/mock-only bulk editor code. + +Нельзя оставлять `barcode` visible, selectable или editable в общем variants editor без API-backed save. + +## Архитектурные правила + +- Frontend GraphQL operations остаются в `admin/src/domains/inventory/products/graphql`. +- Hooks владеют `useQuery`/`useMutation`, loading/error/refetch и нормализацией API errors. +- Components получают generated API types напрямую из `@/graphql/types`. +- Mappers конвертируют editor rows в API inputs, но не выполняют GraphQL calls. +- Pricing-owned fields сохранять через существующий Catalog product update pricing branch. +- Inventory-owned fields сохранять через `inventoryMutation.inventoryItemUpdate`, а не через `catalogMutation.productUpdate`. +- Несмотря на наличие legacy branches `ProductUpdateInput.variants[].inventory/dimensions`, общий variants editor не должен писать inventory-owned fields через Catalog boundary. +- Не запускать `test` и `tsc`. Если нужна машинная проверка, использовать build через project tooling. + +## Текущий baseline + +- `useProductModals.editVariants` уже: + - блокирует повторное открытие через `isEditVariantsLoading`; + - загружает все variants через `useProductVariantsLoader`; + - передаёт `productId`, `variants`, `productOptions`, `defaultCurrency` в `openEditVariantsModal`; + - передаёт `onSave` stub с сообщением `Variant save is read-only in this integration`. +- `EditVariantsModal` уже: + - принимает `onSave?: (rows) => boolean | void | Promise`; + - показывает submit loading; + - закрывается только если `onSave` не вернул `false`; + - сохраняет edits в store при `false`. +- `InventorySection` уже показывает пример API-backed save через: + - `useDefaultWarehouse`; + - `useEnsureVariantInventoryItems`; + - `useUpdateInventoryItems`; + - `prepareChangedVariantInventoryInputs`; + - `variantEditorScope` для warehouse-specific stock values. +- `PricingBlock` уже показывает пример API-backed save через: + - `useUpdateProduct`; + - `prepareChangedVariantPricingInputs`; + - restricted `availableColumns`. + +## API Write Contracts + +### Pricing + +Использовать существующий Catalog product update contract: + +```ts +const operations: ApiProductUpdateInput = { + variants: prepareChangedVariantPricingInputs( + rows, + hydratedVariants, + defaultCurrency, + ), +}; +``` + +Rules: + +- `price` is required when pricing changes. +- `compareAtPrice` can be set or cleared. +- `defaultCurrency` is required when a pricing change is sent. +- Не отправлять pricing branch, если `price` и `compareAtPrice` не изменились. + +### Inventory item, stock, cost, physical fields + +Использовать существующую mutation: + +```graphql +mutation InventoryItemUpdate($input: InventoryItemUpdateInput!) { + inventoryMutation { + inventoryItemUpdate(input: $input) { + inventoryItem { + ...InventoryItemFields + } + userErrors { + ...UserErrorFields + } + } + } +} +``` + +Для одного variant строить один merged `ApiInventoryItemUpdateInput` с изменёнными branches: + +```ts +const input: ApiInventoryItemUpdateInput = { + id: inventoryItem.id, + sku, + stock: { + warehouseId, + onHand, + unavailable, + }, + unitCost: { + currency: defaultCurrency, + amountMinor: row.costPrice, + }, + weight: { + weightGrams: row.weight, + }, + dimensions: { + lengthMm: row.length, + widthMm: row.width, + heightMm: row.height, + }, +}; +``` + +Rules: + +- `id` - `variant.inventoryItem.id`, не `variant.id`. +- Если API variant не имеет `inventoryItem`, перед открытием edit modal явно гидрировать его через `useEnsureVariantInventoryItems`. +- `inventoryItemByVariant` имеет backend side effect, поэтому использовать его только в explicit edit preparation flow, не в passive render. +- `stock.warehouseId` - default warehouse id, resolved before opening editor. +- `onHand` and `unavailable` are non-negative integers. +- `sku` can be set or cleared (`""` -> `null`). +- `costPrice` maps to `unitCost.amountMinor`; `defaultCurrency` is required when cost changes. +- `costPrice` can be set to `0`; clearing an existing unit cost is not supported by current API input and must keep the modal open with an error. +- `weight.weightGrams` хранится и редактируется в base unit `g`. +- `dimensions.*Mm` хранятся и редактируются в base unit `mm`. +- `weight`, `length`, `width`, `height` must be positive integers when sent. +- Because `InventoryItemDimensionsInput` requires all three dimensions, saving dimensions requires all of `length`, `width`, and `height`. +- Clearing existing weight/dimensions is not supported by current API input and must keep the modal open with an error. +- Не отправлять `trackInventory` или `continueSellingWhenOutOfStock` из общего variants save. + +## Field Support Matrix + +| Column | General variants modal save | Owner/API | +|---|---:|---| +| `price` | yes | `ProductUpdateInput.variants[].pricing.amountMinor` | +| `compareAtPrice` | yes | `ProductUpdateInput.variants[].pricing.compareAtMinor` | +| `sku` | yes | `InventoryItemUpdateInput.sku` | +| `onHand` | yes | `InventoryItemUpdateInput.stock.onHand` | +| `unavailable` | yes | `InventoryItemUpdateInput.stock.unavailable` | +| `costPrice` | yes | `InventoryItemUpdateInput.unitCost.amountMinor` | +| `weight` | yes | `InventoryItemUpdateInput.weight.weightGrams` | +| `length` | yes | `InventoryItemUpdateInput.dimensions.lengthMm` | +| `width` | yes | `InventoryItemUpdateInput.dimensions.widthMm` | +| `height` | yes | `InventoryItemUpdateInput.dimensions.heightMm` | +| `media` | no | pictures/media flow is out of scope | +| option columns | no while read-only | not direct table inputs in current grid | +| `reserved` | no | read-only/order-managed | +| `available` | no | calculated | +| `title` | no | display-only column | + +## Modal Editability Contract + +The general variants modal should allow editing every API-backed table input covered by this plan: + +```ts +editableColumns: [ + "price", + "compareAtPrice", + "sku", + "onHand", + "unavailable", + "costPrice", + "weight", + "length", + "width", + "height", +] +``` + +Keep `availableColumns` separate from write scope: + +- `availableColumns` controls visibility for restricted modal modes. +- `editableColumns` controls which visible fields can enter draft edits. +- For the general variants entry point, all read-path columns can remain visible according to user column settings. +- Pricing and Inventory restricted modals keep their current behavior by passing their own restricted `availableColumns` and either omitting `editableColumns` or passing the same list. + +Implementation must guard every write path into the editor store: + +- AG Grid `editable`; +- custom cell selection paste/delete/increment; +- direct `onSetFieldValue`. + +Save button must reflect only edits from fields with API-backed save support. A non-saveable visible field must not create local draft edits. + +## Inventory/Variant Mapper + +Replace the physical-only mapper idea with a combined inventory item mapper, for example: + +```text +admin/src/domains/inventory/products/mappers/product-variant-inventory-item.mapper.ts +``` + +Suggested API: + +```ts +interface PrepareChangedVariantInventoryItemInputsParams { + rows: VariantEditorSaveRow[]; + variants: ApiVariant[]; + warehouseId: string; + defaultCurrency?: CurrencyCode | null; +} + +function prepareChangedVariantInventoryItemInputs( + params: PrepareChangedVariantInventoryItemInputsParams, +): ApiInventoryItemUpdateInput[] +``` + +Mapping rules: + +- Build `variantsById` from loaded `ApiVariant[]`. +- Skip rows whose variant is not in the loaded variants list. +- Require `variant.inventoryItem`; if missing after hydration, throw a user-visible error. +- Compare `sku` against `variant.inventoryItem.sku ?? null`. +- Compare stock against the warehouse stock row matching `warehouseId`. +- Validate stock values as non-negative integers and reject negative availability. +- Compare `costPrice` against `variant.inventoryItem.unitCost?.amountMinor ?? null`. +- Compare physical fields against: + - `variant.inventoryItem.weight?.weightGrams ?? null`; + - `variant.inventoryItem.dimensions?.lengthMm ?? null`; + - `variant.inventoryItem.dimensions?.widthMm ?? null`; + - `variant.inventoryItem.dimensions?.heightMm ?? null`. +- Emit at most one `ApiInventoryItemUpdateInput` per inventory item, merging changed `sku`, `stock`, `unitCost`, `weight`, and `dimensions` branches. +- If no inventory-owned fields changed, return an empty input list. +- Do not include price, media, options, title, or barcode in this mapper. + +Keep the existing `prepareChangedVariantInventoryInputs` for the restricted Inventory widget flow unless the implementation can extend it without changing its behavior. + +## Pricing Mapper + +Reuse existing `prepareChangedVariantPricingInputs`. + +Required adjustment: + +- Ensure it only reads `price` and `compareAtPrice`. +- Do not include inventory-owned fields. +- If no pricing fields changed, return an empty input list. + +## Save Flow + +Update `useProductModals`: + +1. Import and use: + - `useDefaultWarehouse`; + - `useEnsureVariantInventoryItems`; + - `useUpdateInventoryItems`; + - `useUpdateProduct`; + - `prepareChangedVariantPricingInputs`; + - `prepareChangedVariantInventoryItemInputs`. +2. In `handleEditVariants`: + - keep a preparing guard covering the full preparation flow, not only `loadAllProductVariants`; + - `loadAllProductVariants(product)`; + - `ensureVariantInventoryItems(loadedVariants)`; + - resolve default warehouse via `defaultWarehouse ?? refetchDefaultWarehouse()`; + - open `EditVariantsModal` with hydrated variants, default warehouse scope, and full API-backed `editableColumns`. +3. Pass `variantEditorScope: { type: "inventory", warehouseId }` so `onHand`/`unavailable` rows represent the same default warehouse that save will update. +4. Replace read-only `onSave` stub with async save: + - build `pricingUpdates` via `prepareChangedVariantPricingInputs(rows, hydratedVariants, defaultCurrency)`; + - build `inventoryInputs` via `prepareChangedVariantInventoryItemInputs({ rows, variants: hydratedVariants, warehouseId, defaultCurrency })`; + - if any mapper throws, show `message.error(...)` and return `false`; + - if both lists are empty, show `message.info("No variant changes to save")` and return `true`; + - if `pricingUpdates.length > 0`, call `updateProduct({ productId, expectedRevision, operations: { variants: pricingUpdates } })`; + - if pricing update returns errors, show the first error and return `false` before inventory mutation; + - if `inventoryInputs.length > 0`, call `updateInventoryItems(inventoryInputs)`; + - if inventory update returns errors, show the first normalized error and return `false`; + - refresh product details via `options.onProductRefresh?.()`; + - refresh/load variants read path after success if Apollo cache does not make rows visible immediately; + - refresh pricing widget when pricing changed; + - refresh inventory widget when stock, SKU, cost, or physical fields changed; + - show success/warning based on refresh result; + - return `true` to let `EditVariantsModal` close and reset edits. + +Suggested messages: + +- no changes: `No variant changes to save`; +- success: `Variant changes saved`; +- save failure: first API/user error message; +- refresh failure: `Variant changes saved, but refresh failed`. + +Partial failure note: + +- There is no single transaction across Catalog product update and Inventory item updates. +- Run Catalog pricing update first. If it fails, do not run Inventory updates. +- If pricing succeeds and inventory fails, keep the modal open, show the inventory error, and refresh data so already-applied pricing changes are visible. + +## Refresh And Cache + +After successful save: + +- `ProductUpdate` returns product mutation result fields and updates pricing data by variant. +- `InventoryItemUpdate` returns `...InventoryItemFields`, so Apollo can update normalized `InventoryItem` records by ID. +- `onProductRefresh` remains required because product details, variants table, and single-variant shipping section read nested `variant.inventoryItem` fields through product queries. +- Pricing widget refresh is required when `price` or `compareAtPrice` changed. +- Inventory widget refresh is required when `sku`, `onHand`, `unavailable`, `costPrice`, `weight`, or dimensions changed. + +## Implementation Steps + +1. Add `editableColumns?: VariantColumnField[]` to `IEditVariantsModalPayload`. +2. Pass `editableColumns` through `EditVariantsModal` and `VariantsEditorGrid`. +3. Update `useVariantsColumns` so editability is scoped independently from column visibility. +4. Guard `VariantsEditorGrid.handleSetFieldValue` so non-editable fields cannot enter `useVariantsEditorStore.edits`. +5. Pass a filtered `selectableColumns` list to `EditorGrid` so paste/delete/increment cannot edit fields outside `editableColumns`. +6. Remove unsupported `barcode` from the API-backed variants editor column config, default visibility, selectable columns, and general modal editability. +7. Add `product-variant-inventory-item.mapper.ts` and export it from `mappers/index.ts`. +8. Implement inventory item mapper validation and changed-input detection for `sku`, `onHand`, `unavailable`, `costPrice`, `weight`, `length`, `width`, `height`. +9. Reuse `prepareChangedVariantPricingInputs` for `price` and `compareAtPrice`. +10. Update `useProductModals.editVariants`: + - hydrate missing inventory items before opening; + - resolve default warehouse before opening; + - pass `variantEditorScope` and full API-backed `editableColumns`; + - replace read-only save stub with API-backed save for pricing + inventory item fields. +11. Keep Pricing and Inventory restricted flows unchanged. +12. Check that every field editable in the general variants modal is included in save logic. +13. Verify manual behavior. Do not add or edit a changeset file. + +## Verification + +Не запускать `test` и `tsc`. + +Manual checks: + +- Open product details for a product with multiple variants. +- `Edit variants` loads all variants, hydrates inventory items, resolves default warehouse, and opens the modal. +- Editable table inputs are: `price`, `compareAtPrice`, `sku`, `onHand`, `unavailable`, `costPrice`, `weight`, `length`, `width`, `height`. +- Media/picture column is not editable from this general entry point. +- `barcode` is not visible, selectable, or editable in the API-backed variants editor. +- Saving changed price calls Catalog product update with `variants[].pricing`. +- Saving changed SKU calls `inventoryMutation.inventoryItemUpdate` with `sku`. +- Saving changed on-hand/unavailable calls `inventoryMutation.inventoryItemUpdate` with `stock`. +- Saving changed cost calls `inventoryMutation.inventoryItemUpdate` with `unitCost`. +- Saving changed weight calls `inventoryMutation.inventoryItemUpdate` with `weight.weightGrams`. +- Saving changed dimensions calls `inventoryMutation.inventoryItemUpdate` with all `lengthMm`, `widthMm`, and `heightMm`. +- No mutation is sent when there are no supported changes. +- Clearing unsupported required physical/cost values shows an error and keeps the modal open. +- API user errors keep the modal open and preserve edits. +- Successful save closes the modal, refreshes product details, and updated values appear in variants table/widgets. +- Pricing modal still saves only price fields. +- Inventory modal still saves only its restricted inventory fields. + +Если нужна машинная проверка после реализации, запускать только build через project tooling, без `test`/`tsc`. diff --git a/admin/docs/products-page-config-filters-migration-plan.md b/admin/docs/products-page-config-filters-migration-plan.md new file mode 100644 index 000000000..41c071b33 --- /dev/null +++ b/admin/docs/products-page-config-filters-migration-plan.md @@ -0,0 +1,532 @@ +# План миграции Products Page на `usePageConfig` и API-фильтры + +## Цель + +Перевести `admin/src/domains/inventory/products/page/page.tsx` на общий page configuration approach через `usePageConfig`, чтобы Products page использовала один reusable слой для: + +- AG Grid sort state; +- grid state persistence; +- search; +- filter-to-GraphQL `where`; +- cursor pagination variables; +- API `orderBy`. + +Одновременно нужно заменить текущий набор product filters на фильтры, которые реально поддерживаются Admin GraphQL API. + +## Контекст + +План выровнен с: + +- `knowledge/vault/patterns/admin-graphql-layer.md`; +- `knowledge/vault/packages/drizzle-query/filters.md`; +- `knowledge/vault/patterns/currency-handling.md`; +- текущей products GraphQL integration в `admin/src/domains/inventory/products/graphql`; +- текущим reusable hook `admin/src/hooks/use-page-config.ts`; +- текущей media page, которая уже использует `usePageConfig`. + +## Текущий baseline + +- Products page уже API-backed и читает `catalogQuery.products`. +- Products page сейчас вручную собирает: + - `searchValue`; + - `sortModel`; + - `useFilters` с custom `productFilterAdapter`; + - `mapProductSortModelToOrderBy`; + - `useRelayCursorPagination`; + - `listQueryVariables`. +- Сортировка AG Grid уже вынесена в reusable `useGridSort`. +- Более высокий reusable hook `usePageConfig` существует, но Products page его не использует. +- `usePageConfig` сейчас используется только Media page, поэтому изменения в нем нужно делать обратно совместимо. + +## Доступный API contract + +Generated `ApiProductWhereInput` поддерживает больше полей, чем нужно для этой миграции. Для target UX разделять поля, которые реально используются в Products page, и поля, которые просто доступны в generated schema. + +Target search/filter subset: + +| UI use | API field | Filter type | Usage | +|---|---|---|---| +| Title toolbar search | `name` | `ApiStringFilter` | Только toolbar search через `where.name._containsi`; не добавлять отдельный filter chip. | +| Primary category filter | `primaryCategoryId` | `ApiIdFilter` | Relation/entity filter по category id через `_in`. | +| Brand filter | `vendorId` | `ApiIdFilter` | Relation/entity filter по vendor id через `_in`. | +| Min price filter | `minPriceMinor` | `ApiIntFilter` | Price filter, API value in minor units. | +| Max price filter | `maxPriceMinor` | `ApiIntFilter` | Price filter, API value in minor units. | + +Generated поля `handle`, `primaryCategoryName`, и `brandName` не входят в target filter chips для этой миграции: + +- `handle` не входит в requested filter set; +- `primaryCategoryName` остается доступным API string filter field, но Products UX должен использовать entity filter через `primaryCategoryId`; +- `brandName` остается available generated API field, но Brand filter должен быть entity/relation filter по `vendorId`, чтобы UI работал с выбранным vendor id, а не со строковым совпадением имени. + +Generated `ProductOrderField` сейчас поддерживает: + +| UI column | AG Grid `colId` | API order field | +|---|---|---| +| Product | `title` | `ProductOrderField.Name` | +| Min price | `minPriceMinor` | `ProductOrderField.MinPriceMinor` | +| Max price | `maxPriceMinor` | `ProductOrderField.MaxPriceMinor` | +| Category | `primaryCategoryName` | `ProductOrderField.PrimaryCategoryName` | +| Brand | `brand` | `ProductOrderField.BrandName` | + +`Stock` не должен фильтроваться и не должен сортироваться. В API нет product-level stock filter/order field для текущей таблицы. + +## Target UX contract + +| UI control | Target behavior | API mapping | Notes | +|---|---|---|---| +| Title search | Toolbar search only | `where.name._containsi` | UI говорит "title", но generated API filter field сейчас называется `name`. Не добавлять отдельный title filter chip. | +| Primary category | Entity filter | `where.primaryCategoryId._in` | Использовать relation/entity picker для `category`. | +| Min price | Price filter | `where.minPriceMinor` | Значение отправлять в minor units. | +| Max price | Price filter | `where.maxPriceMinor` | Значение отправлять в minor units. | +| Brand | Entity filter | `where.vendorId._in` | Brand должен быть relation/entity filter. В текущей schema brand entity представлена через `Vendor`. | +| Stock | none | none | Не добавлять filter schema entry; колонка остается `sortable: false`. | + +### Brand entity filter contract + +Brand filter должен быть `FilterType.Relation` и должен отправлять entity ids в `vendorId._in`, потому что текущая generated schema представляет brand entity через `ApiProduct.vendor` / `ApiVendor`. + +Backend Catalog Admin API уже содержит vendors read path: + +- `services/catalog/src/api/graphql-admin/schema/base.graphql` exposes `vendor(id: ID!)` and `vendors(...)`; +- `services/catalog/src/api/graphql-admin/schema/product.graphql` defines `Vendor`, `VendorConnection`, and `VendorEdge`; +- `services/catalog/src/resolvers/admin/QueryResolver.ts` implements `vendor` and `vendors`. + +Current admin client generated types expose `ApiVendor` through `ApiProduct.vendor`, but `admin/src/graphql/types.ts` does not currently expose `catalogQuery.vendors` / `catalogQuery.vendor` entrypoints. Поэтому Brand filter implementation must include admin GraphQL integration/codegen for vendors before enabling the `vendor` relation filter in Products. + +Important schema drift detail: + +- `services/catalog/src/api/graphql-admin/schema/base.graphql` already contains `vendor` / `vendors`; +- `services/catalog/src/resolvers/admin/QueryResolver.ts` already implements both read paths; +- current exported federation schema files do not expose those fields yet: `infra/federation/schema/catalog-admin.graphql` and `infra/federation/supergraph-admin.graphql` currently include `type Vendor`, but `CatalogQuery` lacks `vendor` / `vendors`; +- `admin/codegen.ts` reads `../infra/federation/supergraph-admin.graphql`, so Admin codegen alone cannot expose vendors until the Admin federation schema export/composition is refreshed. + +Do not degrade Brand to `brandName` string filter. `brandName` can stay available in generated API types, but it is not the target UX for this migration. + +## Shared changes + +### 1. Ослабить generic constraint в `usePageConfig` + +`usePageConfig` сейчас ограничивает `TWhereInput extends Record`. Generated GraphQL input types вроде `ApiProductWhereInput` могут не проходить это ограничение без кастов. + +Изменить constraints на `object`: + +```ts +export interface UsePageConfigOptions< + TData, + TWhereInput extends object, + TOrderField extends string, +> { ... } +``` + +То же сделать в `UsePageConfigReturn`, `usePageConfig` и helper types вроде `FilterTransformer`. + +Цель: использовать generated API input types напрямую, без локального `Record` alias. + +### 2. Не ломать Media page + +Media page уже использует `usePageConfig` и `CursorPagination`. + +При изменениях: + +- сохранить текущие returned fields: `first`, `last`, `after`, `before`, `pageSize`, `setPageSize`, `goToNextPage`, `goToPrevPage`, `getRangeStart`, `getRangeEnd`; +- не переименовывать existing public API без отдельной миграции Media page; +- новые helpers добавлять additive. + +### 3. Добавить reusable filter transformers + +Добавить локально в Products page или в shared hook рядом с `createStartsWithTransformer`: + +```ts +createRelationInTransformer(fieldName) +createMinorUnitPriceTransformer(fieldName) +``` + +Expected behavior: + +- relation values должны храниться в `IFilterValue.value` как `string[]` ids и мапиться в `{ [fieldName]: { _in: ids } }`; +- relation transformer поддерживает только `_in`; empty values возвращают `null`; unsupported operators возвращают `null`, если fallback небезопасен; +- relation transformer technically optional when the relation control always returns a clean `string[]`, because `usePageConfig` already handles `FilterOperator.In` + array as `_in`; if added, use it as a guard/normalizer, not as a new behavior layer; +- price transformer is mandatory for Products price filters: current `FilterValueControl` stores numeric input as `[value]`, while default `usePageConfig` scalar conversion would send the array directly to `_eq` / `_gt` / `_gte` / `_lt` / `_lte`; +- price transformer supports only `_eq`, `_gt`, `_gte`, `_lt`, `_lte`; +- price transformer unwraps the first non-empty value, verifies it is finite numeric input, converts user-facing major units to integer minor units, and maps to `{ [fieldName]: { [operator]: minorValue } }`; +- empty or invalid price values return `null`, чтобы filter был пропущен; +- unsupported price operators return `null`, not `undefined`, because default conversion is unsafe for `FilterType.Price` array values. + +Preferred UX: пользователь вводит major units, transformer uses the current fixed two-decimal convention and sends `Math.round(value * 100)` to the API. This matches current product price display, where `formatPrice` formats minor-unit amounts by dividing by 100. Do not reuse raw minor-unit semantics from variant editor inputs for these filter controls. + +### 4. Relation controls для entity filters + +`FilterType.Relation` уже есть, но `relationControlRegistry` сейчас не имеет registered controls. + +Нужно добавить generic или specific relation control: + +- `CategoryRelationControl` для `entity: "category"`; +- `VendorRelationControl` для `entity: "vendor"` как обязательная часть brand entity filter; +- или generic `EntityPickerRelationControl`, который берет `entity`, открывает entity picker modal и отображает выбранные labels. + +Important: + +- entity keys должны совпадать с registered picker configs. Сейчас category picker registered как `entityType: "category"`, не `"Category"`. +- category picker уже есть: `admin/src/shared/components/entity-picker-modal/configs/category-picker-config.ts`. +- vendor picker сейчас отсутствует в admin, его нужно добавить для brand entity filter после подключения vendors query в generated admin GraphQL client. +- relation controls must store only id arrays in `IFilterValue.value`; labels/entities may be cached locally for display, but GraphQL filter transformer input must remain ids. +- if implementing generic `EntityPickerRelationControl`, register it in `relationControlRegistry` for each supported entity key (`"category"`, `"vendor"`) and ensure the registration module is imported during Admin startup. + +### 5. Admin vendors GraphQL integration + +Перед включением Brand filter в `filterSchema` добавить admin read integration для vendors: + +- refresh/export Catalog Admin schema into `infra/federation/schema/catalog-admin.graphql` so `CatalogQuery` contains `vendor(id: ID!)` and `vendors(...)`, and the schema contains `VendorConnection`, `VendorWhereInput`, `VendorOrderByInput`, and `VendorOrderField`; +- compose/refresh `infra/federation/supergraph-admin.graphql` so the same vendor query fields and inputs exist in the actual schema consumed by Admin codegen; +- run Admin GraphQL codegen through the approved Shopana project workflow, not by hand-editing `admin/src/graphql/types.ts`, so generated types contain `catalogQuery.vendors` / `catalogQuery.vendor`, `ApiVendorConnection`, `ApiVendorWhereInput`, `ApiVendorOrderByInput`, and `ApiCatalogQueryVendorsArgs`; +- добавить vendors query operation under admin inventory domain. Если отдельного `vendors` module нет, допустимо держать module-local read path under `admin/src/domains/inventory/products/graphql` только для picker use case; +- добавить `useVendors` hook, который возвращает generated API objects directly according to `knowledge/vault/patterns/admin-graphql-layer.md`; +- добавить `vendor-picker-config.ts` for entity picker with `entityType: "vendor"`, columns `Vendor`/`Name`, pagination, optional search by `name._containsi`, and optional default order by name; +- зарегистрировать vendor picker config in `admin/src/shared/components/entity-picker-modal/register.ts`; +- only after this, enable `{ entity: "vendor", payloadKey: "vendorId" }` in Products filter schema. + +This is an implementation dependency, not a backend API blocker. + +## Products filter schema target + +Файл: `admin/src/domains/inventory/products/page/filter-schema.ts` + +Удалить из filter chips: + +- `name`, потому что title идет через toolbar search; +- `handle`, потому что он не входит в requested filter set; +- old category string filter по `primaryCategoryName`, потому что нужен entity filter. + +Target schema: + +```ts +export const filterSchema: IFilterSchema[] = [ + { + key: "primaryCategory", + label: "Primary category", + description: "Filter by primary category", + type: FilterType.Relation, + operators: relationOperators, + payloadKey: "primaryCategoryId", + entity: "category", + }, + { + key: "minPrice", + label: "Min price", + description: "Filter by product minimum price", + type: FilterType.Price, + operators: priceOperators, + payloadKey: "minPriceMinor", + }, + { + key: "maxPrice", + label: "Max price", + description: "Filter by product maximum price", + type: FilterType.Price, + operators: priceOperators, + payloadKey: "maxPriceMinor", + }, + { + key: "brand", + label: "Brand", + description: "Filter by brand/vendor", + type: FilterType.Relation, + operators: relationOperators, + payloadKey: "vendorId", + entity: "vendor", + }, +]; +``` + +If implementing incrementally, omit the `brand` schema entry until both dependencies are ready: + +- Admin generated GraphQL client exposes `catalogQuery.vendor` / `catalogQuery.vendors`; +- vendor picker config and `relationControlRegistry` entry for `entity: "vendor"` are registered. + +## Products page migration + +### 1. Replace manual local state + +Remove from `page.tsx`: + +- `searchValue` state; +- `sortModel` state; +- `useFilters`; +- `productFilterAdapter`; +- `buildStringFilter`; +- `mapProductSortModelToOrderBy`; +- `resetKey`; +- direct `useRelayCursorPagination`; +- direct `useGridState`; +- direct `useGridSort`. + +Keep: + +- `gridRef`; +- selection state; +- modal stack behavior; +- delete/refetch behavior; +- column definitions. + +### 2. Add `usePageConfig` + +Target setup: + +```ts +const productSortFieldMapping: SortFieldMapping = { + title: ProductOrderField.Name, + minPriceMinor: ProductOrderField.MinPriceMinor, + maxPriceMinor: ProductOrderField.MaxPriceMinor, + primaryCategoryName: ProductOrderField.PrimaryCategoryName, + brand: ProductOrderField.BrandName, +}; + +const buildProductSearchCondition = ( + search: string, +): Partial => ({ + name: { _containsi: search }, +}); + +const productFilterTransformers = { + primaryCategoryId: createRelationInTransformer( + "primaryCategoryId", + ), + minPriceMinor: createMinorUnitPriceTransformer( + "minPriceMinor", + ), + maxPriceMinor: createMinorUnitPriceTransformer( + "maxPriceMinor", + ), + vendorId: createRelationInTransformer("vendorId"), +}; + +const pageConfig = usePageConfig< + ApiProduct, + ApiProductWhereInput, + ProductOrderField +>({ + gridRef, + storageKey: "products-grid-state", + filterSchema, + sortFieldMapping: productSortFieldMapping, + defaultPageSize: 20, + buildSearchCondition: buildProductSearchCondition, + filterTransformers: productFilterTransformers, +}); +``` + +Important: keep `sortFieldMapping`, `buildSearchCondition`, and `filterTransformers` as stable references, as shown above. Do not pass them inline from the component render. `usePageConfig` recomputes `where` / `orderBy` from these inputs and resets cursor pagination when they change. Treat this as a hard requirement, not a style preference. + +Do not add a string Brand filter. If `vendor` relation picker is not available, implement it before enabling the Brand filter. + +### 3. Build query variables from `pageConfig` + +```ts +const listQueryVariables = useMemo( + () => ({ + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: (pageConfig.orderBy ?? null) as ApiProductOrderByInput[] | null, + }), + [ + pageConfig.first, + pageConfig.after, + pageConfig.last, + pageConfig.before, + pageConfig.where, + pageConfig.orderBy, + ], +); +``` + +Longer-term improvement: type `usePageConfig` orderBy return so `OrderByInput[]` is assignable without a cast. + +### 4. Replace `FilterWidget` + +```tsx + +``` + +Search placeholder can stay "Search products..." or become "Search by title...". The backend mapping remains `where.name._containsi`. + +### 5. Replace grid sort/state props + +```tsx + + ... + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + onSortChanged={pageConfig.onSortChanged} +/> +``` + +Keep `defaultColDef.comparator = () => 0`, because sorted order must come from the server via `orderBy`, not client-side resorting of the current page. + +### 6. Replace pagination integration + +Minimal migration should match current `usePageConfig` consumer style and use `CursorPagination` directly: + +```tsx + { + if (pageInfo?.endCursor) { + pageConfig.goToNextPage(pageInfo.endCursor); + } + }} + onPrev={() => { + if (pageInfo?.startCursor) { + pageConfig.goToPrevPage(pageInfo.startCursor); + } + }} + onPageSizeChange={pageConfig.setPageSize} +/> +``` + +Optional follow-up: refactor `usePageConfig` to use `useRelayCursorPagination` internally and expose a `pagination` object compatible with `RelayCursorPagination`. Do that only after migrating Media page or preserving old fields. + +## Product columns after migration + +Keep sortable columns: + +- Product/title; +- Min price; +- Max price; +- Category; +- Brand. + +Keep `Stock`: + +- visible if product list still needs it; +- `sortable: false`; +- no filter schema entry; +- no `ProductOrderField` mapping; +- no `ApiProductWhereInput` mapping. + +## API gaps and decisions + +### Category entity filter + +Available enough for first pass: + +- Product API supports `primaryCategoryId`; +- category picker config exists; +- relation control registration is missing. + +Decision: add relation control support and use `primaryCategoryId._in`. + +### Brand entity filter + +Partial support: + +- Product API supports `vendorId`; +- `ApiProduct.vendor` exists; +- backend Catalog Admin API supports `vendor` / `vendors`; +- exported Admin federation schema currently does not expose `CatalogQuery.vendor` / `CatalogQuery.vendors`, so supergraph/codegen must be refreshed before frontend vendors operations can compile; +- admin generated client currently does not expose `catalogQuery.vendors`; +- no vendor picker config was found in admin client. + +Decision: + +- refresh/export/compose Admin GraphQL schema, run Admin codegen, add `useVendors`, and add vendor picker config; +- use `vendorId._in` for Brand filter after vendors read path is available in admin client; +- do not implement a string Brand filter for this migration. + +### Price filters + +Available: + +- Product API supports `minPriceMinor` and `maxPriceMinor`; +- filter UI has `FilterType.Price`; +- project money convention uses minor units. + +Decision: use minor units in API. Preferred UI input is major units with transformer to minor units. + +For Products page filters this transformer is required, because the current filter control stores price input as an array while GraphQL scalar operators expect a number. + +## Implementation phases + +### Phase 1: Shared hook compatibility + +- Relax `usePageConfig` generic constraints to accept generated GraphQL input types. +- Relax helper generic constraints too, including `FilterTransformer` and `createStartsWithTransformer`, so generated input types can be used without `Record` aliases. +- Add reusable relation and minor-unit price transformers if they are useful beyond Products. +- Make the Products price transformer unwrap `[value]` from `FilterType.Price`, validate finite input, convert major units to minor units with `Math.round(value * 100)`, and avoid unsafe default conversion. +- If adding relation transformer, keep it as a guard/normalizer for id arrays; default `FilterOperator.In` conversion in `usePageConfig` is already safe for clean `string[]` values. +- Keep `sortFieldMapping`, `buildSearchCondition`, and `filterTransformers` stable top-level constants or memoized references for every `usePageConfig` consumer. +- Stabilize the existing Media page `filterTransformers` object or otherwise ensure `usePageConfig` does not reset pagination every render because of inline object identity. +- Keep Media page compiling with the old returned fields. + +### Phase 2: Admin vendors integration + +- Refresh/export Catalog Admin schema into `infra/federation/schema/catalog-admin.graphql` and verify `CatalogQuery.vendor`, `CatalogQuery.vendors`, `VendorConnection`, `VendorWhereInput`, and `VendorOrderByInput` exist there. +- Compose/refresh `infra/federation/supergraph-admin.graphql` and verify the same vendor fields/types exist there, because Admin codegen reads this file. +- Run Admin GraphQL codegen through the approved Shopana workflow and verify `admin/src/graphql/types.ts` exposes `catalogQuery.vendor`, `catalogQuery.vendors`, `ApiVendorConnection`, `ApiVendorWhereInput`, `ApiVendorOrderByInput`, and `ApiCatalogQueryVendorsArgs`. +- Do not hand-edit generated GraphQL types. +- Add vendors query operation and `useVendors` hook. +- Add/register `vendor-picker-config.ts`. +- Keep Brand filter disabled until this phase is complete and Phase 3 registers the vendor relation control. + +### Phase 3: Relation controls + +- Add/register category relation control for filters. +- Confirm selected relation values are stored as IDs. +- Add/register vendor relation control for Brand using the vendor picker config. +- Keep `IFilterValue.value` for relation filters as `string[]` ids. Relation controls may keep a local label cache for display, but GraphQL transformer input must remain ids. +- If using generic `EntityPickerRelationControl`, register it for concrete entity keys (`"category"` and `"vendor"`) in `relationControlRegistry`; entity picker config registration alone is not enough. +- Confirm relation control registration code is imported during Admin startup before Products page renders. + +### Phase 4: Products filter schema + +- Replace `filter-schema.ts` with target filters. +- Remove handle/name filter chips. +- Use title toolbar search through `buildSearchCondition`. +- Keep stock out of filter schema. +- Add Brand only after Phase 2 provides vendors read path and vendor picker, and Phase 3 registers a vendor relation control. + +### Phase 5: Products page migration + +- Replace manual page state with `usePageConfig`. +- Build GraphQL query variables from `pageConfig`. +- Wire `FilterWidget`, `AgGridReact`, and pagination to `pageConfig`. +- Keep existing delete/refetch and modal behavior. +- Keep server-side sorting behavior. +- Use stable references for `sortFieldMapping`, `buildSearchCondition`, and `filterTransformers`. + +### Phase 6: Verification + +Do not run `test` or `tsc` per project instruction. + +When implementation changes code and a new build is needed, run project build through the approved project workflow. + +Manual verification checklist: + +- Generated/admin schema verification before enabling Brand: + - `infra/federation/schema/catalog-admin.graphql` contains `CatalogQuery.vendor`, `CatalogQuery.vendors`, `VendorConnection`, `VendorWhereInput`, and `VendorOrderByInput`; + - `infra/federation/supergraph-admin.graphql` contains the same vendor read path; + - `admin/src/graphql/types.ts` contains `ApiCatalogQueryVendorsArgs`, `ApiVendorConnection`, `ApiVendorWhereInput`, and `ApiVendorOrderByInput`. +- Search by title changes `where.name._containsi` and resets to first page. +- Primary category filter sends `primaryCategoryId._in`. +- Primary category relation control displays selected category labels but stores/sends category ids. +- Min price filter input `12.34` sends `minPriceMinor` with numeric minor-unit value `1234`, not `[12.34]`. +- Max price filter input `12.34` sends `maxPriceMinor` with numeric minor-unit value `1234`, not `[12.34]`. +- Brand filter sends `vendorId._in`. +- Brand relation control displays selected vendor labels but stores/sends vendor ids. +- Sort Product/Min price/Max price/Category/Brand updates `orderBy` and resets to first page. +- Stock column does not show sort affordance and does not appear in filters. +- Pagination still uses `pageInfo.endCursor/startCursor`. +- Grid state persistence still uses `products-grid-state`. +- Changing unrelated component state on Products or Media page does not reset cursor pagination because `usePageConfig` references are stable. diff --git a/admin/docs/review-details-card-modal-redesign-wireframes.md b/admin/docs/review-details-card-modal-redesign-wireframes.md new file mode 100644 index 000000000..49d5977bb --- /dev/null +++ b/admin/docs/review-details-card-modal-redesign-wireframes.md @@ -0,0 +1,1086 @@ +# Review Details: redesign details card и section edit modals + +## Цель + +Спроектировать новый UI для `ReviewDetailsCard`, details modal и связанных модалок редактирования секций. Новый экран должен выглядеть как часть той же системы, что `ProductDetailsCard` и `CategoryDetailsCard`: компактная summary-карточка сверху, последовательные `Paper`-секции, локальные действия секций и предсказуемый Modal Stack. + +Документ описывает presentation и interaction design. Он не меняет GraphQL-контракт и не предлагает отдельную страницу вместо существующего modal flow. + +Референсы в текущем Admin UI: + +- `admin/src/domains/inventory/products/components/product-details-card`; +- `admin/src/domains/inventory/products/components/product-info-header`; +- `admin/src/domains/inventory/categories/components/category-details-card`; +- `admin/src/domains/inventory/categories/components/category-info-header`; +- `admin/src/domains/inventory/components/entity-details-sections`; +- `admin/src/domains/inventory/components/entity-edit-forms`; +- `admin/src/domains/media/components/entity-media-gallery`; +- `admin/src/layouts/modals`; +- `admin/src/ui-kit/paper`; +- `admin/src/ui-kit/kpi-tile`; +- `admin/src/ui-kit/copyable-chip`. + +## Что меняется относительно чернового UI + +Текущий экран показывает данные, но воспринимается как техническая выгрузка: много одинаковых `Descriptions`, повторяющиеся статусы, несколько видимых кнопок `Edit`, необработанный JSON и слабая визуальная иерархия. + +В новом варианте: + +1. Details modal получает стабильный заголовок `Review details`; пользовательский title живёт только в summary-card. +2. Верхняя карточка повторяет композицию Product/Category info header: status/meta, title, chips, actions, divider и реальные KPI. +3. Сначала показываются данные для принятия решения: текст review и moderation reports. Технические интеграционные поля находятся внизу. +4. Engagement metrics показываются один раз в header и не дублируются отдельной тяжёлой секцией. +5. Все секционные действия используют существующий `EditAction` с `⋯`; явные primary-кнопки остаются только в modal header. +6. Большая общая edit-modal разделяется по агрегатным API-секциям. Каждая форма отправляет только собственный subtree `ReviewUpdateInput`. +7. Standalone `Trust & incentive` и `Author & source` удаляются: audit summary живёт в header, verification — в Moderation, incentive disclosure — рядом с review content. +8. Raw source metadata не участвует в основном reading flow и открывается отдельным `View technical metadata` из header overflow. +9. Empty/loading/error states используют те же визуальные принципы, что Product/Category details. + +## Визуальные правила + +- Контент details и edit modals использует стандартный `ModalLayout`, `max-width: 800px`. +- Между карточками — `12px`, внутри `Paper` — текущий token-based padding. +- Одна карточка отвечает на один пользовательский вопрос: «что написал клиент», «какое принято решение», «к чему относится review». +- `Typography.Title level={3}` используется один раз — в `ReviewInfoHeader`. +- `PaperHeader` используется для всех секций; локальное действие находится справа. +- Цвет используется только семантически: status, report severity, verification и errors. +- Системные ID показываются через `CopyableChip` или `Typography.Text copyable`, а не как обычный длинный текст. +- Не использовать отдельные декоративные градиенты, большие цветные hero-блоки и новые card primitives. +- Тексты controls остаются на английском, как в существующем Admin UI. + +## Information architecture + +```text +Review details modal +├── ReviewInfoHeader +│ ├── lifecycle status + updated meta +│ ├── title + overall rating +│ ├── author/trust badges + review ID +│ └── helpful / unhelpful / open reports / replies KPI +├── ReviewContentSection +├── ReviewModerationSection +│ └── decision + abuse reports +├── ReviewSubjectSection +│ └── product / variant / order evidence +├── ReviewRatingsSection +├── ReviewMediaSection +├── ReviewRepliesSection +└── ReviewExternalReferencesSection +``` + +Порядок намеренный: review можно прочитать и модерировать в верхней части modal, не прокручивая через source metadata, integration IDs и sync state. + +## Modal Stack + +```text +Reviews page +└── Review details level 0 + ├── Edit review content level 1 + ├── Edit reviewer level 1 + │ └── Customer picker level 2 + ├── Edit product & purchase level 1 + │ ├── Product picker level 2 + │ └── Variant picker level 2 + ├── Edit ratings level 1 + ├── Review moderation level 1 + ├── Edit purchase verification level 1 + ├── Edit incentive disclosure level 1 + ├── View technical metadata level 1 + ├── Edit customer media level 1 + │ ├── Media picker / upload level 2 + │ └── Edit media details level 2 + └── External reference create/edit level 1 +``` + +После сохранения дочерняя modal вызывает `onSaved`, details query refetch выполняется до закрытия child modal, затем пользователь возвращается к актуальному Review Details. Отмена вложенного picker не меняет draft родительской формы. + +Рекомендуемые section keys: + +```ts +type ReviewEditSection = + | "content" + | "reviewer" + | "subject" + | "ratings" + | "moderation" + | "verification" + | "incentive" + | "media"; +``` + +## Review Details Modal + +### Полный wireframe + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Review details │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [API error alert — only when present] │ +│ │ +│ ┌─ ReviewInfoHeader ─────────────────────────────────────────────────┐ │ +│ │ [PUBLISHED ✓] Created Jul 15 · Updated Jul 16 · Storefront [⋯] │ │ +│ │ │ │ +│ │ Excellent sound, comfortable fit │ │ +│ │ ★ ★ ★ ★ ☆ 4 / 5 │ │ +│ │ │ │ +│ │ By Maria Johnson [Customer] [Verified purchase] │ │ +│ │ [ID 01J8…] │ │ +│ │ │ │ +│ │ ───────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Helpful │ │ Unhelpful │ │ Open reports│ │ Replies │ │ │ +│ │ │ 24 │ │ 2 │ │ 1 │ │ 3 │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Review content ────────────────────────────────────────────── [⋯] ┐ │ +│ │ I've used these headphones every day for three months. The sound │ │ +│ │ is detailed, the fit stays comfortable, and battery life matches │ │ +│ │ the description. The case scratches more easily than expected. │ │ +│ │ │ │ +│ │ [DISCLOSED] Customer received a sample for an honest review. │ │ +│ │ │ │ +│ │ Locale: English (en) 487 characters │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Moderation ────────────────────────────────────────────────── [⋯] ┐ │ +│ │ [◷ Pending ][✓ Published ][⊗ Rejected ] read-only │ │ +│ │ Moderated Jul 16, 14:32 by Admin │ │ +│ │ Internal note: Relevant first-hand product experience. │ │ +│ │ │ │ +│ │ [shield] Purchase verification [VERIFIED] Order match · Jul 16 │ │ +│ │ │ │ +│ │ Abuse reports 1 open / 2 total │ │ +│ │ ┌────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ [OPEN] [Spam or promotion] Alex Brown Jul 16, 11:40│ │ │ +│ │ │ “Contains a link to another store.” │ │ │ +│ │ └────────────────────────────────────────────────────────────────┘ │ │ +│ │ [Show all reports] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Product & purchase ───────────────────────────────────────── [⋯] ┐ │ +│ │ [bag] Sony WH-1000XM5 [Open product] │ │ +│ │ Variant: Midnight black │ │ +│ │ │ │ +│ │ Order ID [gid://… copy] Order line [gid://… copy] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Review rating ─────────────────────────────────────────── [Edit] ┐ │ +│ │ Overall 4.0 ★★★★☆ Criteria: Sound 5 · Comfort 4 · Build 3 │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Customer media (3) ───────────────────────────────────────── [⋯] ┐ │ +│ │ [image 1 ✓] [image 2 ◷] [video 3 ⊗] [+ add] [placeholder ...] │ │ +│ │ Icon badges show status; full moderation data opens per item. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Replies (3) ──────────────────────────────────────────────────────┐ │ +│ │ Store Support [Official] [Published] Jul 16, 15:10 │ │ +│ │ Thank you for the feedback. We can help with a replacement case. │ │ +│ │ 4 helpful · 0 reports │ │ +│ │ ───────────────────────────────────────────────────────────────── │ │ +│ │ Maria Johnson [Customer] [Published] Jul 16, 15:22 │ │ +│ │ Thanks — I will contact support. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ External references (1) ─────────────────────────── [+ Add] ┐ │ +│ │ [SYNCED] Trustpilot · REVIEW · TP-18372 [⋯] │ │ +│ │ Last synced Jul 16, 14:40 · Open external link │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### ReviewInfoHeader + +![Review info header](assets/review-details-redesign/01-review-info-header.png) + +Композиция повторяет `ProductInfoHeader` и `CategoryInfoHeader`, но показывает только релевантные review actions. + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ [PUBLISHED ✓] Created Jul 15 · Updated Jul 16 · Storefront [⋯] │ +│ │ +│ Excellent sound, comfortable fit │ +│ ★ ★ ★ ★ ☆ 4 / 5 │ +│ │ +│ By Maria Johnson [Customer] [Verified purchase] │ +│ [ID 01J8A7C2] │ +│ │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Helpful │ │ Unhelpful │ │ Open reports │ │ Replies │ │ +│ │ 24 │ │ 2 │ │ 1 │ │ 3 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +`PaperHeader title`: + +- status `Tag` с icon и tooltip: + - `PENDING` — gold, clock, `Awaiting moderation`; + - `PUBLISHED` — green, check, `Visible in published review surfaces`; + - `REJECTED` — red, circle-x, `Rejected by moderation`; +- компактная audit line: `Created {createdAt} · Updated {updatedAt} by {actor} · {source}`; +- если `updatedAt` ещё не запрашивается details fragment, показывать `Created {createdAt} · {source}` без пустых separators. + +`PaperHeader actions`: + +- text button `LinkOutlined` — copy current Admin URL; +- `Dropdown` с `MoreOutlined`: + +```text +Edit review content +Edit reviewer +Edit product & purchase +Edit ratings +──────────────────────── +Review moderation +Edit purchase verification +Edit incentive disclosure +View technical metadata +──────────────────────── +Redact personal content danger +Delete review danger +``` + +Dropdown является fallback navigation. Основной путь редактирования — `EditAction` в соответствующей секции. + +Title area: + +- title с ellipsis максимум две строки, fallback `Untitled review`; +- `Rate disabled` + текстовое значение `{rating} / 5`, чтобы рейтинг не зависел только от формы звёзд; +- author display name, author type `Tag`, условный `Verified purchase` badge; +- `CopyableChip label="ID"` с коротким display value. + +Author email и linked customer ID не повторяются в details. Они остаются доступны в `Edit reviewer`. `View technical metadata` открывает read-only modal/drawer с principal ID, idempotency key и formatted source metadata; эти поля не занимают место в основном scroll. + +KPI panel: + +- `KPITile` для `metrics.likeCount`; +- `KPITile` для `metrics.dislikeCount`; +- `KPITile` для `metrics.openReportCount`; +- `KPITile` для `replies.totalCount`. + +`PeriodSwitch` не используется: API содержит aggregate counters без временных рядов. Нельзя показывать фиктивную динамику по образцу mock KPI Product/Category. + +### ReviewContentSection + +![Review content section](assets/review-details-redesign/02-review-content.png) + +```text +┌─ Review content ────────────────────────────────────────────────── [⋯] ┐ +│ Full plain-text review body. Preserve user line breaks. │ +│ No rich HTML rendering and no truncation in details view. │ +│ │ +│ [gift] Incentive disclosure [DISCLOSED] │ +│ Customer received a sample for an honest review. │ +│ │ +│ English (en) 487 characters │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- `Typography.Paragraph`, `whiteSpace: pre-wrap`, комфортный line-height. +- Body показывается полностью; details modal уже имеет собственный scroll. +- Locale выводится читаемым label + code через `shopLocales`, а не только `en`. +- Character count — secondary text, без отдельного `Descriptions` ради одного поля. +- Incentive alert рендерится только при `isIncentivized=true`; это disclosure marker из API, а не сведения о начисленном вознаграждении. +- При `isIncentivized=false` не показывать `Not incentivized`, пустой placeholder или отдельный вертикальный отступ. +- Section menu содержит `Edit review content` и условный `Edit incentive disclosure`; обе modal отправляют независимые subtrees. + +### ReviewModerationSection + +![Review moderation section](assets/review-details-redesign/03-review-moderation.png) + +Секция объединяет текущие `Moderation` и reports. Это убирает дублирование и держит решение рядом с evidence. + +```text +┌─ Moderation ─────────────────────────────────────────────────────── [⋯] ┐ +│ Moderation status │ +│ ┌──────────────────┬──────────────────┬──────────────────┐ │ +│ │ ◷ Pending │ ✓ Published │ ⊗ Rejected │ read-only │ +│ └──────────────────┴──────────────────┴──────────────────┘ │ +│ Moderated Jul 16, 14:32 by Admin │ +│ │ +│ Internal note │ +│ Relevant first-hand product experience. │ +│ │ +│ [shield] Purchase verification [VERIFIED] Order match · Jul 16, 14:30 │ +│ │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ │ +│ Abuse reports 1 open / 2 total │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ [OPEN] [Spam or promotion] Jul 16, 11:40 │ │ +│ │ Alex Brown · alex@example.com │ │ +│ │ “Contains a link to another store.” │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ [DISMISSED] [Not relevant] Jul 15, 18:20 │ │ +│ │ Anonymous reporter │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ [Show all reports (2)] │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- Верхняя часть повторяет прежний horizontal status pattern из версии до `034f0c32`: три равных segment, текущий status выделен семантическим цветом. +- В details это read-only presentation: segments не имеют `onChange`, hover/focus state и не должны объявляться screen reader как selectable controls. +- В edit modal тот же визуальный pattern становится настоящим `Segmented block`; одинаковая геометрия сохраняет узнаваемость view/edit state. +- Под status strip показываются moderated timestamp и moderator. +- `moderationNote` отображается как нормальный текст; при отсутствии — `No internal note` secondary. +- Purchase verification — одна компактная строка: status, method и verified timestamp. Она заменяет отдельный `Trust & incentive` Paper. +- `Verified purchase` в header остаётся только summary badge; полные verification values не дублируются там. +- Ниже показывается `openReportCount / reportCount`. +- Сначала открытые reports, затем закрытые; внутри одинаковой группы — новые первыми. +- По умолчанию видны максимум три report rows, затем `Show all reports (N)`. +- Report row показывает status, human-readable reason, reporter, date и details. +- `last-child` border отсутствует, как в существующем reports list. +- Section menu содержит два независимых действия: `Review moderation` и `Edit purchase verification`. Report resolution не смешивается ни с одним из них. +- При отсутствии reports использовать `EntityDetailsEmptyState`, а не большой `Empty` illustration. + +### ReviewSubjectSection + +![Product and purchase section](assets/review-details-redesign/04-product-purchase.png) + +```text +┌─ Product & purchase ─────────────────────────────────────────────── [⋯] ┐ +│ [bag] Sony WH-1000XM5 [Open product] │ +│ Variant: Midnight black │ +│ │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ │ +│ Order ID Order line ID │ +│ [gid://shopana/Order/8452 copy] [gid://shopana/OrderLine/31 copy] │ +│ │ +│ Purchase evidence is linked to this review but is not displayed publicly.│ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- Product title — link/button, открывающий существующий Product Details modal. +- Variant — link на Variant details, если entity доступна; иначе copyable ID/title. +- Order и Order line остаются copyable global IDs: admin federation entity для orders пока отсутствует. +- Не показывать fake product image: текущий review details fragment возвращает только `product { id title }`. +- Action: `Edit product & purchase`. + +### ReviewRatingsSection + +![Review ratings section](assets/review-details-redesign/05-review-ratings.png) + +```text +┌─ Review rating ─────────────────────────────────────────────── [Edit] ┐ +│ Submitted by the reviewer │ +│ │ +│ ┌─ Overall rating ────────┐ ┌─ Criteria breakdown ── [4 criteria] ┐ │ +│ │ │ │ Individual scores for this review │ │ +│ │ 4.0 │ │ │ │ +│ │ ★ ★ ★ ★ ☆ │ │ Sound quality ▰ ▰ ▰ ▰ ▰ 5.0 │ │ +│ │ 4 out of 5 │ │ Comfort ▰ ▰ ▰ ▰ ▱ 4.0 │ │ +│ │ │ │ Build quality ▰ ▰ ▰ ▱ ▱ 3.0 │ │ +│ └─────────────────────────┘ │ Value for money ▰ ▰ ▰ ▰ ▱ 4.0 │ │ +│ │ ──────────────────────────────────── │ │ +│ │ ✓ All assigned criteria answered │ │ +│ └──────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- Название `Review rating` и helper text `Submitted by the reviewer` явно отделяют оценку конкретного review от агрегированного product rating. +- Слева — компактный overall summary: крупное значение, disabled `Rate`, текстовое `{value} out of 5`. +- Справа — вложенная панель `Criteria breakdown`: count badge, пояснение и criterion ratings в API order. +- Criterion row состоит из title, пятисегментной шкалы и числового значения. Сегменты визуально отличаются от stars общей оценки и лучше сканируются в плотном списке. +- Footer `All assigned criteria answered` показывается только когда API подтверждает полноту применимых criteria; при отсутствии такой проверки footer не выводится. +- Если detailed ratings отсутствуют, панель показывает компактный empty state `No criterion scores were submitted` без count badge и completion footer. +- На узкой ширине колонки складываются вертикально: overall summary сверху, criteria panel снизу; title и value каждой строки остаются на одной линии. +- Единственное действие секции — явная кнопка `Edit`, открывающая `Edit ratings`; ambiguous overflow menu здесь не нужен. + +### ReviewMediaSection + +![Customer media section](assets/review-details-redesign/06-customer-media.png) + +Details presentation переиспользует визуальный grid `MediaSection` Product Details: + +```text +┌─ Customer media (5) ────────────────────────────────────────────── [⋯] ┐ +│ ┌─────────────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ [✓] │ │ [✓] │ │ [◷] │ │ [⊗] │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ image 1 │ │ image 2 │ │ video 3 │ │ image 4 │ │ +│ │ │ │ │ │ ▶ │ │ │ │ +│ │ hover: Preview │ └─────────┘ └─────────┘ └─────────┘ │ +│ └─────────────────────┘ ┌─────────┐ ┌─────────┐ ┌╌╌╌╌╌╌╌╌╌┐ │ +│ │ [✓] │ │ │ ╎ + ╎ │ +│ │ image 5 │ │ empty │ ╎ Add media╎ │ +│ └─────────┘ └─────────┘ └╌╌╌╌╌╌╌╌╌┘ │ +│ │ +│ Select an item to preview or moderate it. │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- `hasFeatured={false}`; +- `MediaPreview` для изображений и video; +- максимум 12 cells в overview, `+N` для остатка; +- upload cell открывает edit media modal; +- tabs `Published / Pending / Rejected` отсутствуют: при небольшом количестве media они занимают место и скрывают общий контекст review; +- каждый item показывает icon-only moderation badge в одном и том же top-right slot: `CheckCircle`, `Clock`, `CircleX`; +- badge использует icon + semantic color, имеет `Tooltip` и `aria-label`; цвет не является единственным способом различить state; +- полный status text, caption, moderation note и audit metadata доступны только в item details modal; +- click по thumbnail открывает preview/details; action `Edit details` доступен также из item overflow menu. + +Общий grid расширяется optional slots. Не нужно копировать media preview, placeholder, DnD и keyboard interaction в новый review-only component. + +#### Расширение EntityMediaGallery для moderation + +Текущий `EntityMediaGallery` принимает только `ApiFile[]`, тогда как moderation metadata принадлежит `ReviewMedia`, а не `File`. Поэтому gallery сохраняет file-based contract, а Review adapter держит metadata по `file.id` и передаёт optional render/action slots: + +```ts +interface IEntityMediaGalleryProps { + // existing props remain unchanged + renderItemBadge?: (file: ApiFile, index: number) => React.ReactNode; + renderListMeta?: (file: ApiFile, index: number) => React.ReactNode; + getItemMenuItems?: ( + file: ApiFile, + index: number, + ) => MenuProps["items"]; + onEditItem?: (file: ApiFile, index: number) => void; + editItemLabel?: string; +} +``` + +Review-specific draft остаётся за пределами shared gallery: + +```ts +interface ReviewMediaDraftItem { + file: ApiFile; + caption: string | null; + status: ReviewContentStatus; + moderationNote: string | null; + moderatedByPrincipalId: string | null; + moderatedAt: string | null; + moderationDirty: boolean; +} + +const metadataByFileId = new Map(items.map((item) => [item.file.id, item])); +``` + +Правила extension: + +- existing Product/Category consumers не передают новые props и визуально не меняются; +- built-in menu items `Preview`, `Set as featured`, `Delete` объединяются с `getItemMenuItems`, сохраняя test IDs на menu item object; +- `renderItemBadge` рендерится поверх grid thumbnail, но не внутри drag handle и не перекрывает overflow action; +- в list mode `renderListMeta` может показывать icon + полный text status, потому что там достаточно горизонтального места; +- reorder/add/delete возвращают `ApiFile[]`; Review adapter синхронно перестраивает draft, сохраняя metadata существующих file IDs; +- новая media получает `PENDING`, `moderationNote=null`, `moderatedAt=null`. + +### ReviewRepliesSection + +![Review replies section](assets/review-details-redesign/07-review-replies.png) + +```text +┌─ Replies (3) ────────────────────────────────────────────────────────────┐ +│ Store Support [Official] [Published] Jul 16, 15:10 │ +│ Thank you for the feedback. We can help with a replacement case. │ +│ 4 helpful · 0 reports │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ Maria Johnson [Customer] [Published] Jul 16, 15:22 │ +│ Thanks — I will contact support. │ +│ 1 helpful · 0 reports │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ Store Support [Official] [Pending] Jul 16, 15:40 │ +│ A private support case has been created for you… [Show more] │ +│ 0 helpful · 1 report │ +│ │ +│ [Show all replies (3)] │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- Reply row: author, official/customer tag, publication status, created date, body, helpful/report counts. +- Длинный body ограничивается четырьмя строками с `Show more`. +- По умолчанию показываются первые пять replies; `Show all (N)` раскрывает список внутри секции. +- Empty state: `No replies yet` + пояснение без action. +- На этом этапе секция read-only: текущий Review Details не имеет отдельного reply management flow. Не показывать неработающую кнопку `Manage`. + +### Почему нет Trust & incentive и Author & source + +Эти standalone sections удалены намеренно: они повторяли header и показывали технические scalar values как равнозначный business content. + +```text +Удалённый блок Новое место +───────────────────────────── ─────────────────────────────────────────── +Author name / type ReviewInfoHeader +Source / created / updated компактная audit line в ReviewInfoHeader +Verified purchase summary badge в ReviewInfoHeader +Verification status/details compact row в ReviewModerationSection +Incentive disclosure conditional alert в ReviewContentSection +Author email / customer ID Edit reviewer modal +Principal / idempotency / JSON View technical metadata из header overflow +``` + +`View technical metadata` — read-only utility modal/drawer, не details section: + +```text +┌────────────────────────────────────────────────────────────────────┐ +│ × Technical metadata │ +├────────────────────────────────────────────────────────────────────┤ +│ Principal ID [principal_01J… copy] │ +│ Idempotency key [storefront-review-8452 copy] │ +│ Source Storefront │ +│ Source metadata │ +│ { "device": "mobile", "campaign": "post-purchase" } │ +└────────────────────────────────────────────────────────────────────┘ +``` + +JSON используется в token-based code container с horizontal scroll. Пустой `{}` не создаёт отдельную строку. Modal не содержит `Save`, потому что source audit data immutable в Review Admin flow. + +### ReviewExternalReferencesSection + +![External references section](assets/review-details-redesign/10-external-references.png) + +```text +┌─ External references (2) ────────────────────────────────────── [+ Add] ┐ +│ [SYNCED] Trustpilot · REVIEW · TP-18372 [⋯] │ +│ Last synced Jul 16, 14:40 · Open external link │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ [FAILED] Bazaarvoice · REVIEW · BV-99102 [⋯] │ +│ Sync failed Jul 16, 13:05 │ +│ Authentication token expired │ +└───────────────────────────────────────────────────────────────────────────┘ + +Empty state: + +┌─ External references (0) ────────────────────────────────────── [+ Add] ┐ +│ [link icon] No external references │ +│ Connect this review to an external review system. │ +│ [Add external reference] │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- Header action — small `+ Add`, потому что это create collection action, а не редактирование всей секции. +- Row: sync status tag, system/type/id, last synced или last error, optional external URL. +- Row `EditAction` открывает существующий `ExternalReferenceModal` с `externalReference`. +- `FAILED` row визуально акцентирует `lastError`, но вся карточка не становится красной. +- Empty state сохраняет contextual action `Add external reference`. + +## Section Edit Modals + +### Общий pattern + +Каждая edit modal использует: + +```text +ModalLayout +├── ModalHeader: close / stable title / primary Save +└── scrollable body, max-width 800 + ├── API Alert, only when present + └── one or more Paper sections +``` + +Общие правила: + +1. `Save` disabled, пока detail loading, форма невалидна, submit выполняется или edit form не dirty. +2. Любое изменение вызывает `setDirty(true)`; закрытие обрабатывается стандартным Modal Stack confirmation. +3. Field errors находятся под конкретным control; operation/network error — `Alert` над первой `Paper`. +4. После submit ошибка не закрывает modal и не очищает draft. +5. При success: refetch details, success toast, `setDirty(false)`, `forcePop()`. +6. Каждая modal отправляет только свою секцию и текущий `expectedRevision`. +7. Валидация и API error mapping должны переиспользовать `reviewFormSchema`/mapper rules, разделённые на section schemas. +8. Form controls имеют видимые labels; placeholders не заменяют labels. + +### 1. Edit Review Content + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit review content [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ Content ──────────────────────────────────────────────────────────┐ │ +│ │ Locale * │ │ +│ │ [English (en)_______________________________________________⌄] │ │ +│ │ │ │ +│ │ Title │ │ +│ │ [Excellent sound, comfortable fit____________________] 41 / 150 │ │ +│ │ │ │ +│ │ Review * │ │ +│ │ [I've used these headphones every day for three months... ] │ │ +│ │ [ ] │ │ +│ │ [ ] │ │ +│ │ 487 / 5000 │ │ +│ │ At least 20 characters. Plain text; line breaks are preserved. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- `Select showSearch` для `shopLocales`. +- `Input` title, max 150. +- `Input.TextArea autoSize={{ minRows: 8, maxRows: 16 }}`, body 20–5000. +- Rating, product и author здесь отсутствуют. +- Submit subtree: `content.text`. + +### 2. Edit Reviewer + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit reviewer [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Reviewer identity ────────────────────────────────────────────────┐ │ +│ │ Author type * │ │ +│ │ [Customer____________________________________________________⌄] │ │ +│ │ │ │ +│ │ Linked customer * │ │ +│ │ [Maria Johnson____________________________________] [Select] │ │ +│ │ │ │ +│ │ Display name * Email │ │ +│ │ [Maria Johnson______________] [maria@example.com____________] │ │ +│ │ │ │ +│ │ ℹ Name and email are stored as the review author snapshot. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Author type — `Select`, потому что enum содержит больше трёх вариантов. +- Для `CUSTOMER` customer обязателен; используется существующий Customer Picker. +- Для guest/staff/external customer selection скрывается или очищается после подтверждения. +- При выборе Customer display name/email заполняются snapshot values, но остаются редактируемыми. +- Source channel, principal ID и idempotency key не редактируются этой modal. +- Submit subtree: `content.author`. + +### 3. Edit Product & Purchase + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit product & purchase [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Review subject ───────────────────────────────────────────────────┐ │ +│ │ Product * │ │ +│ │ [Sony WH-1000XM5__________________________________] [Select] │ │ +│ │ │ │ +│ │ Variant │ │ +│ │ [Midnight black___________________________________] [Select] │ │ +│ │ Variant options are restricted to the selected product. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Order evidence ───────────────────────────────────────────────────┐ │ +│ │ Order ID Order line ID │ │ +│ │ [gid://shopana/Order/…_______] [gid://shopana/OrderLine/…____] │ │ +│ │ Orders currently use global IDs; no order picker is available. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Product — existing Product Picker, single selection. +- Variant — existing Variant Picker, single selection and constrained to selected Product. +- Если Product меняется и текущий Variant ему не принадлежит, Variant очищается с понятным inline notice. +- Order IDs optional и `allowClear`. +- Submit subtree: `subject`. + +### 4. Edit Ratings + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit ratings [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Overall rating ───────────────────────────────────────────────────┐ │ +│ │ Overall * │ │ +│ │ [★] [★] [★] [★] [☆] 4 / 5 │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Rating criteria ──────────────────────────────────────────────────┐ │ +│ │ Sound quality [★] [★] [★] [★] [★] 5 / 5 │ │ +│ │ Comfort [★] [★] [★] [★] [☆] 4 / 5 │ │ +│ │ Build quality [★] [★] [★] [☆] [☆] 3 / 5 │ │ +│ │ │ │ +│ │ Values are saved as one complete criterion rating set. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- `Rate` + numeric text для каждого значения. +- Criteria rows используют `criterion.id` как stable key. +- Список не позволяет менять criterion definitions; они настраиваются в отдельном management flow. +- Если criteria отсутствуют, вторая Paper не рендерится. +- Submit subtree: `rating { overall, criteria }`. + +### 5. Review Moderation + +![Edit review moderation modal](assets/review-details-redesign/11-edit-review-moderation.png) + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Review moderation [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Moderation decision ──────────────────────────────────────────────┐ │ +│ │ [◷ Pending ] [✓ Published ] [⊗ Rejected ] │ │ +│ │ │ │ +│ │ ┌────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Published │ │ │ +│ │ │ Visible on product pages and included in rating aggregates. │ │ │ +│ │ └────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ Internal moderation note │ │ +│ │ [Relevant first-hand product experience. ] │ │ +│ │ [ ] │ │ +│ │ 55 / 1000 │ │ +│ │ This note is never shown to customers. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Current decision: Published · Jul 16, 14:32 · Admin │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Восстанавливается точный interaction pattern до `034f0c32`: full-width `Segmented block` с иконками `ClockCircleOutlined`, `CheckCircleOutlined`, `CloseCircleOutlined`. +- Consequence copy показывается один раз в отдельной semantic context panel для выбранного status, а не трижды внутри segments. +- Details и form используют одинаковый horizontal layout; разница выражена поведением: details static/read-only, form selectable и сохраняется через `Save`. +- Rejected требует non-empty moderation note. +- Current moderator/time — read-only secondary line вне editable fields. +- Reports видны в parent details и не повторяются внутри edit form. +- Submit subtree: `content.moderation`. + +### 6. Edit Purchase Verification + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit purchase verification [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Purchase verification ────────────────────────────────────────────┐ │ +│ │ Status * │ │ +│ │ [ Unverified ] [ Verified ] [ Revoked ] │ │ +│ │ │ │ +│ │ Method Verified at │ │ +│ │ [Order match________________] [Jul 16, 2026 14:30___________] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Verification status — `Segmented` из трёх значений. +- Method и verified datetime доступны для `VERIFIED`; при другом status UI явно показывает, будут ли значения сохранены или очищены согласно mapper policy. +- Не использовать одно поле `verificationStatus`, как в текущем draft: API также поддерживает method/time. +- Submit subtree: `verification`. + +### 7. Edit Incentive Disclosure + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit incentive disclosure [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Disclosure ───────────────────────────────────────────────────────┐ │ +│ │ [●] This review was incentivized │ │ +│ │ │ │ +│ │ Public disclosure * │ │ +│ │ [Customer received a sample for an honest review. ] │ │ +│ │ This is a disclosure marker; Shopana does not issue a reward. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- `Switch` управляет `isIncentivized`; disclosure появляется и становится required только при enabled. +- При disabled API получает `isIncentivized=false`, а disclosure очищается согласно существующему contract. +- Не добавлять reward type, amount, coupon или payout: таких полей и выдачи вознаграждения в API нет. +- Submit subtree: `incentive`. + +### 8. Edit Customer Media + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit customer media [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Customer media ────────────────────────────── 3 / 8 ─────────────┐ │ +│ │ [Add from library] [Upload] │ │ +│ │ │ │ +│ │ ↕ [thumb] headphones-front.jpg JPG · 1.8 MB [Published] [⋯] │ │ +│ │ ↕ [thumb] travel-case.jpg JPG · 1.1 MB [Published] [⋯] │ │ +│ │ ↕ [video] unboxing.mp4 MP4 · 8.4 MB [Pending] [⋯] │ │ +│ │ │ │ +│ │ Drag to reorder. The first item is not treated as featured. │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Переиспользуется `EntityMediaGallery` в `viewMode="list"`: + +- media picker, upload modal, preview, DnD и remove остаются общими; +- `hasFeatured={false}`; +- limit берётся из review configuration (`maxReviewMediaCount`), а не дублируется в нескольких компонентах; +- optional item metadata slot показывает review media status; +- item action `Edit details` открывает вложенную modal. + +Nested media details: + +![Edit review media details modal](assets/review-details-redesign/12-edit-review-media-details.png) + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit media details [Apply] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Preview ───────────────────┐ ┌─ Details ───────────────────────┐ │ +│ │ │ │ Caption │ │ +│ │ [ image / video ] │ │ [Headphones and travel case…] │ │ +│ │ │ │ │ │ +│ │ headphones-side.jpg │ │ Moderation status │ │ +│ │ JPG · 1.8 MB │ │ [◷ Pending][✓ Published][⊗ Rej.]│ │ +│ └─────────────────────────────┘ │ │ │ +│ │ [REJECTED context message] │ │ +│ │ │ │ +│ │ Moderation note * │ │ +│ │ [Image contains personal… ] │ │ +│ │ Moderated Jul 16 by Admin │ │ +│ └────────────────────────────────┘ │ +│ │ +│ ℹ Apply updates the draft. Save the media gallery to persist changes. │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Status использует тот же horizontal `Segmented block`, что review moderation: `Pending`, `Published`, `Rejected` с icon и text. +- Context panel объясняет consequence только выбранного status. +- При `Rejected` moderation note обязательна; максимум 1000 characters. При других statuses note optional, но существующая note не очищается молча. +- `Pending` очищает `moderatedByPrincipalId` и `moderatedAt`; `Published`/`Rejected` записывают текущего admin и timestamp согласно существующему update script. +- `Apply` валидирует item и изменяет только draft родительской media modal. Server update выполняется один раз по `Save` родителя, потому что `ReviewUpdateInput.media` является complete replacement. +- Закрытие nested modal без `Apply` не меняет parent draft. Закрытие dirty parent modal использует стандартное Modal Stack confirmation. +- Parent Save отправляет `caption` для каждого item, но `moderation` subtree — только когда `moderationDirty=true`. Если отправлять moderation для всех items при обычном reorder, текущий backend перезапишет moderator/timestamp. +- Для unchanged item отсутствие `moderation` заставляет update script сохранить existing status, note, moderator и timestamp по `fileId`. + +### 9. External Reference Create/Edit + +Существующая modal сохраняется, но получает более спокойную иерархию: + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Edit external reference [⋯] [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌─ Identity ─────────────────────────────────────────────────────────┐ │ +│ │ Review Excellent sound, comfortable fit (read-only) │ │ +│ │ External system * [Trustpilot________] Type * [REVIEW________] │ │ +│ │ External ID * [TP-18372_______________________________] │ │ +│ │ External URL [https://…________________________________] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Synchronization ──────────────────────────────────────────────────┐ │ +│ │ Direction * [Bidirectional________________________________⌄] │ │ +│ │ Status [Synced_______________________________________⌄] │ │ +│ │ Last synced Jul 16, 14:40 Last error — │ │ +│ │ │ │ +│ │ > Advanced: ETag, checksum and metadata │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- Metadata JSON, ETag и checksum скрыты в `Collapse` `Advanced`. +- Sync state отображается в той же synchronization Paper, а не отдельной карточкой. +- Delete находится в header `⋯` и требует danger confirmation; отдельная большая `Danger zone` Paper не нужна. +- Enum labels форматируются human-readable: `Bidirectional`, а не `bidirectional`. + +## Destructive confirmations + +### Redact personal content + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Redact personal content? │ +│ │ +│ Personal author data and redactable review content will be │ +│ irreversibly replaced. This action cannot be undone. │ +│ │ +│ [Cancel] [Redact] │ +└──────────────────────────────────────────────────────────────┘ +``` + +`Redact` — danger button. После success details остаётся открытой и refetch показывает redacted state. + +### Delete review + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Delete review? │ +│ │ +│ The review will be archived with a soft delete and removed │ +│ from active review surfaces. │ +│ │ +│ [Cancel] [Delete] │ +└──────────────────────────────────────────────────────────────┘ +``` + +После success details modal закрывается и reviews list refetch выполняется. + +## Component reuse matrix + +| UI responsibility | Переиспользовать | Решение | +|---|---|---| +| Modal shell | `ModalLayout`, `ModalHeader` | Без нового shell/footer | +| Section surface | `Paper`, `PaperHeader` | Один pattern для всех sections | +| Local section action | `EditAction` | `⋯` с понятным menu label | +| Status/meta header | composition Product/Category `InfoHeader` | Новый `ReviewInfoHeader`, та же структура | +| IDs | `CopyableChip` | Review, customer, order IDs | +| Header metrics | `KPITile` | Только реальные aggregate values | +| Dates | `formatDetailDate` | Один formatter вместо `toLocaleString()` в каждом компоненте | +| Empty content | `EntityDetailsEmptyState` | Review-specific copy/icon | +| Media overview | Product `MediaSection` primitives + `MediaPreview` | `hasFeatured=false`, metadata slots | +| Media editor | `EntityMediaGallery` | List mode, DnD, picker, upload, preview | +| Media moderation extension | optional `renderItemBadge`, `renderListMeta`, `getItemMenuItems`, `onEditItem` slots | Shared gallery остаётся file-based; Review adapter владеет moderation draft | +| Product/customer/variant selection | existing Entity Picker hooks/configs | Не создавать review-specific pickers | +| Locale | `shopLocales` | Human label + locale code | +| Forms | `react-hook-form`, section Zod schemas | Dirty/errors/submit единообразны | +| Unsaved close | Modal Stack built-in confirmation | Не создавать локальный confirm | +| External references | existing `ExternalReferenceModal` | Улучшить layout, сохранить API flow | + +## Что должно стать отдельными Review components + +```text +admin/src/domains/customer-content/reviews/components/review-details-card/ +├── review-details-card.tsx +├── review-details-card.styles.ts +├── review-info-header.tsx +├── sections/ +│ ├── review-content-section.tsx +│ ├── review-moderation-section.tsx +│ ├── review-subject-section.tsx +│ ├── review-ratings-section.tsx +│ ├── review-media-section.tsx +│ ├── review-replies-section.tsx +│ └── review-external-references-section.tsx +└── hooks/ + └── use-review-modals.ts +``` + +`ContentDetailsSections` не должен продолжать рендерить один большой generic хвост для Review. Общие formatter, report row, author summary и external reference row можно вынести в маленькие shared components, но порядок и composition остаются domain-specific. + +Edit modals рекомендуется разделить физически, как в Category Details: + +```text +admin/src/domains/customer-content/reviews/modals/ +├── review-details-modal/ +├── edit-review-content-modal/ +├── edit-reviewer-modal/ +├── edit-review-subject-modal/ +├── edit-review-ratings-modal/ +├── edit-review-moderation-modal/ +├── edit-review-verification-modal/ +├── edit-review-incentive-modal/ +├── review-technical-metadata-modal/ +├── edit-review-media-modal/ +└── edit-review-media-item-modal/ +``` + +## Section/API ownership + +| Section modal | Единственный update subtree | +|---|---| +| Edit review content | `{ content: { text: { title, body, locale } } }` | +| Edit reviewer | `{ content: { author: { ... } } }` | +| Edit product & purchase | `{ subject: { productId, variantId, orderId, orderLineId } }` | +| Edit ratings | `{ rating: { overall, criteria } }` | +| Review moderation | `{ content: { moderation: { status, moderationNote } } }` | +| Edit purchase verification | `{ verification: { ... } }` | +| Edit incentive disclosure | `{ incentive: { isIncentivized, disclosure } }` | +| Edit customer media | `{ media: [...] }` complete replacement | +| External reference | отдельные external reference create/update/delete mutations | + +Это ключевое правило redesign: открытие `Edit review content` не должно повторно отправлять author, product, rating, moderation, verification, incentive или media из устаревшего form snapshot. + +## Data readiness + +Для wireframe используются уже доступные поля `ReviewDetailsFields`. Небольшие read additions допустимы без изменения API schema: + +- добавить `updatedAt`, если header показывает updated meta; +- добавить lifecycle timestamps только если они реально выводятся; +- не запрашивать product media только ради декоративного thumbnail; +- не делать отдельные queries для author, reports или external references, уже вложенных в details operation. + +Review details query остаётся источником истины. Edit modal получает `entityId`, повторно загружает актуальную entity и использует `revision` для optimistic concurrency. + +Review media moderation уже поддерживается Admin API полями `status`, `moderationNote`, `moderatedByPrincipalId`, `moderatedAt` и input `ReviewMediaSyncItemInput.moderation`; schema extension для editor не требуется. + +Customer-facing delivery требует отдельного обязательного правила: наружу возвращаются только media со `status=PUBLISHED`. Текущий repository loader получает все review media без status filter, поэтому icon badge сам по себе ещё не обеспечивает скрытие `PENDING`/`REJECTED`. До появления storefront resolver это фиксируется как implementation requirement, а не считается готовым поведением API. + +При conflict: + +```text +This review changed after the editor was opened. +[Reload latest data] +``` + +Автоматически повторять update с новой revision нельзя. + +## Loading, empty и error states + +### Details loading + +- Skeleton повторяет высоту header card и первых двух секций. +- Не показывать 14 одинаковых paragraph rows. +- Header modal сразу имеет стабильный title `Review details`. + +### Not found + +```text +Review not found +It may have been deleted or is no longer available. +[Close] +``` + +### Section empty states + +| Section | Copy | +|---|---| +| Media | `No customer media` / `This review has no attached photos or videos.` | +| Replies | `No replies yet` / `No customer or official replies have been added.` | +| Reports | `No abuse reports` / `No customers have reported this review.` | +| Detailed ratings | `No criterion ratings` | +| External references | `No external references` + `Add external reference` | + +### Errors + +- Detail query failure — global `Alert` вместо частично пустой card. +- Edit network/operation failure — `Alert` над form sections. +- Field validation — inline, с focus на первом invalid control. +- Media item error — привязан к конкретной stable `fileId` row. +- External sync failure — row-level message; остальная details card остаётся нейтральной. + +## Responsive behavior + +При ширине content меньше `640px`: + +- KPI panel становится `2 × 2`, затем одной колонкой на очень узком viewport; +- `Descriptions` — одна колонка; +- label/value pairs Ratings располагаются вертикально; +- Product/Variant и Order/Order line form fields становятся вертикальными; +- media grid уменьшает число колонок, но сохраняет квадратные cells; +- `Rate` не переносит отдельные stars на новую строку: numeric value может перейти ниже целиком; +- Modal header primary action остаётся видимой; +- body scroll не двигает header. + +## Accessibility и keyboard behavior + +- Status всегда имеет текстовый label, не только цвет. +- Icon-only actions имеют tooltip и `aria-label`. +- `Rate` сопровождается `{value} out of 5` для screen reader. +- Edit menu labels называют объект: `Edit ratings`, а не просто `Edit`. +- После открытия edit modal focus попадает в первое editable поле. +- После validation error focus переходит к первому invalid field. +- `Esc` закрывает только верхний уровень stack; dirty form показывает стандартное confirmation. +- Media reorder доступен keyboard sensor и альтернативными move actions. +- `Show all` и `Show more` — настоящие buttons, не clickable text. +- External links обозначаются как открывающиеся в новой вкладке. + +## Acceptance criteria + +- Review Details визуально следует Product/Category details pattern: summary header + `Paper` sections + local `EditAction`. +- Modal header называется `Review details`; review title не дублируется в двух headers. +- Header KPI использует только реальные review counters и не показывает fake trends/periods. +- Content и moderation доступны без прокрутки через technical metadata. +- Engagement counters не дублируются отдельной секцией. +- Reports находятся рядом с moderation decision и сортируются open-first. +- Standalone `Trust & incentive` и `Author & source` отсутствуют; их уникальные данные распределены по header, content и moderation без повторов. +- Raw source metadata не участвует в основном scroll и доступна через отдельный read-only utility view. +- Review content, reviewer, subject, ratings, moderation, verification, incentive и media имеют независимые edit flows. +- Каждая edit modal отправляет только собственный `ReviewUpdateInput` subtree. +- Product, Variant, Customer и Media selection переиспользуют существующие pickers. +- Media переиспользует gallery/preview/upload primitives и сохраняет caption/moderation metadata. +- Media grid не содержит status tabs и full-text thumbnail badges; status представлен icon-only badge с Tooltip и accessible name. +- Каждый media item можно открыть в nested details modal и изменить `Pending / Published / Rejected`; rejected требует moderation note. +- Reorder/add/delete не сбрасывают moderation metadata существующих items и не переписывают moderation audit без реального изменения. +- Customer-facing review media исключает `PENDING` и `REJECTED`; Admin details продолжает видеть все statuses. +- External reference rows открываются в create/edit варианте существующей modal. +- Redact и Delete имеют разные, точные consequence messages. +- Loading, not found, empty, conflict и API error states описаны и не уничтожают form draft. +- Узкий viewport не создаёт горизонтальный scroll основной формы. +- Все actions доступны с клавиатуры и имеют текстовые accessible names. diff --git a/admin/docs/search-product-boosts-synonyms-modal-wireframes.md b/admin/docs/search-product-boosts-synonyms-modal-wireframes.md new file mode 100644 index 000000000..a8080510b --- /dev/null +++ b/admin/docs/search-product-boosts-synonyms-modal-wireframes.md @@ -0,0 +1,629 @@ +# Product Boosts и Search Synonym Groups: Modal Stack UI, UX и API + +## Цель + +Спроектировать модалки создания и редактирования: + +- `Product boost` — связывает поисковые фразы с товарами, которые нужно поднимать в выдаче; +- `Search synonym group` — объединяет равнозначные поисковые формулировки для одной локали. + +Модалки должны использовать существующий Admin UI layout `Modal Stack`, работать с текущим GraphQL-контрактом listing/search и одинаково предсказуемо вести себя в create/edit режимах. + +Документ опирается на: + +- `admin/src/layouts/modals`; +- `admin/src/shared/components/entity-picker-modal/product-picker-modal.tsx`; +- `admin/src/domains/discovery/search/product-boosts`; +- `admin/src/domains/discovery/search/synonyms`; +- `services/listing/src/api/graphql-admin/schema/search.graphql`; +- `knowledge/vault/patterns/admin-graphql-layer.md`. + +## Общие UX-принципы + +1. Для каждой сущности используется одна форма с режимом `create | edit`, чтобы поля, валидация и API mapping не расходились. +2. Основная модалка открывается из страницы списка. Клик по строке открывает edit, primary action страницы — create. +3. Header всегда остаётся видимым. В нём находятся закрытие, заголовок и единственное primary-действие `Create` или `Save`. +4. Тело скроллится независимо от header. Контент следует стандартной ширине `ModalLayout` (`max-width: 800px`). +5. Поля сгруппированы в `Paper`-секции. Обязательные настройки видны сразу, без вкладок и скрытых accordion-блоков. +6. Списки фраз и синонимов редактируются как упорядоченные строки, а не как свободный comma-separated текст. Так проще показать ошибку конкретного элемента и не терять пользовательскую пунктуацию. +7. `enabled` доступен при создании и редактировании. Значение по умолчанию для новой сущности — `true`, но пользователь видит и контролирует его до сохранения. +8. Изменение формы вызывает `setDirty(true)`. Закрытие через `×`, `Esc` или возврат по стеку показывает стандартное подтверждение Modal Stack. +9. Во время submit поля не очищаются и модалка не закрывается. Кнопка показывает loading и блокирует повторную отправку. +10. API validation errors показываются возле соответствующих полей; общий или сетевой error — в `Alert` над первой секцией. Ошибка не должна уничтожать draft. + +## Modal Stack + +### Типы и payload + +```ts +type SearchConfigurationModalMode = "create" | "edit"; + +interface IProductBoostModalPayload extends IModalStackPayload { + mode: SearchConfigurationModalMode; + entityId?: string; + onSaved?: () => Promise | unknown; +} + +interface ISynonymGroupModalPayload extends IModalStackPayload { + mode: SearchConfigurationModalMode; + entityId?: string; + onSaved?: () => Promise | unknown; +} +``` + +Рекомендуемые registered types: + +```text +search-product-boost +search-synonym-group +``` + +Обе definitions используют: + +```ts +{ + confirmOnDirtyClose: true, + closeConfirmMessage: "Discard unsaved changes?", +} +``` + +`entityId` обязателен только в edit mode. Нельзя передавать весь объект строки как источник истины: данные могли измениться после загрузки списка. + +### Стек для Product boost + +```text +Product boosts page +└── Product boost modal level 0 + └── Product picker modal level 1 +``` + +`Product picker` открывается через существующий Modal Stack type и получает: + +```ts +{ + selectionMode: "multi", + initialSelection: products.map((product) => product.id), + maxSelection: 50, + onConfirm: (entities, ids) => mergeSelectedProducts(entities, ids), +} +``` + +После confirm picker закрывается, а родительская форма остаётся открытой и dirty. Отмена picker не меняет draft родительской формы. + +### Открытие с list pages + +- Product boosts: primary action `Create product boost`; row click — edit выбранного boost. +- Synonyms: primary action `Create synonym group`; row click — edit выбранной группы. +- После успешного сохранения вызывается `payload.onSaved` или refetch соответствующего connection query. +- Пока edit modal открывается, строковые данные из grid можно использовать только для skeleton title; форма заполняется detail query. + +## Product Boost Modal + +### Основной wireframe + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × New product boost [Create] │ +│ / Edit product boost [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [API error alert — only when present] │ +│ │ +│ ┌─ General ───────────────────────────────────────────────────────────┐ │ +│ │ Name * Locale * │ │ +│ │ [Summer footwear____________] [English (en)_______________⌄] │ │ +│ │ │ │ +│ │ Status │ │ +│ │ [● Enabled] Apply this boost in storefront search │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Trigger phrases ───────────────────────────────────────────────────┐ │ +│ │ Search queries that activate this boost. 2 / 20 │ │ +│ │ │ │ +│ │ 1 [summer shoes____________________________________] [remove] │ │ +│ │ 2 [shoes for summer________________________________] [remove] │ │ +│ │ │ │ +│ │ [+ Add phrase] │ │ +│ │ Inline error for row 2 │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Boosted products ──────────────────────────────────────────────────┐ │ +│ │ Products shown higher for matching queries. 3 / 50 │ │ +│ │ │ │ +│ │ AG Grid │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Product Status Actions │ │ │ +│ │ ├──────────────────────────────────────────────────────────────┤ │ │ +│ │ │ [img] Linen sneakers Active [Remove] │ │ │ +│ │ │ [img] Canvas low tops Draft [Remove] │ │ │ +│ │ │ [img] Leather sandals Active [Remove] │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ [+ Select products] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### Секции и controls + +| Поле | Control | Правило | +|---|---|---| +| `name` | `Input`, maxLength 128, character counter near limit | После trim от 1 до 128 Unicode code points. Это внутреннее понятное название, не поисковая фраза. | +| `locale` | searchable `Select` | Только локали текущего store, для которых search доступен. Показывать label и code. | +| `enabled` | `Switch` | Default `true`. Disabled boost хранится, но не применяется в storefront search. | +| `phrases` | ordered repeatable inputs | От 1 до 20. Пустые строки не отправлять; duplicate после normalization должен быть подсвечен. | +| `products` | AG Grid selected-products table + nested picker | Read model приходит как `Product[]`; при записи mapper отправляет от 1 до 50 уникальных Product global IDs. | + +Кнопка `Create/Save` disabled, если: + +- detail/settings data ещё загружаются; +- search settings не инициализированы; +- submit выполняется; +- форма невалидна; +- edit form не изменена. + +В create mode одна пустая строка phrase добавлена заранее. `Enter` в последней заполненной строке добавляет следующую; `Backspace` в пустой строке удаляет её, если остаётся хотя бы одна строка. Drag-and-drop не требуется: порядок сохраняется сверху вниз и может изменяться кнопками move up/down, доступными с клавиатуры. + +### AG Grid выбранных товаров + +Секция `Boosted products` в основной модалке является таблицей и должна использовать существующий Admin UI AG Grid setup (`AgGridReact`, `useAgGridTheme`, зарегистрированные community modules), а не самодельный список строк. + +Колонки: + +| Column | Содержимое | Поведение | +|---|---|---| +| `Product` | thumbnail, title; fallback `Unavailable product` + ID | `flex: 1`, не sortable | +| `Status` | существующий product status renderer | фиксированная ширина, не sortable | +| `Actions` | icon/button `Remove` | фиксированная ширина, keyboard accessible | + +Таблица локальная: в edit mode её `rowData` сразу строится из `SearchProductBoost.products`, полученных тем же detail query. Отдельный запрос в catalog для заполнения таблицы запрещён. Внутри основной modal таблице не нужны pagination, filters, row selection и server-side sorting. Порядок строк соответствует массиву `products`; удаление строки сразу обновляет form field и dirty state. + +После работы с picker выбранные `IPickableEntity` объединяются с уже загруженными `ApiProduct` по `id`, а порядок восстанавливается по `ids` из `onConfirm`. Для таблицы достаточно полей, возвращаемых boost detail query и picker: `id`, `title`, thumbnail и publish status. GraphQL mapper при submit преобразует актуальный selection в `productIds: products.map(({ id }) => id)`. + +Высота ограничивается контентом до разумного максимума, после чего scroll происходит внутри grid. У grid должны быть стабильный `getRowId`, стандартная modal row height и `data-testid="product-boost-selected-products-grid"`. Empty state внутри секции сообщает `No products selected` и оставляет рядом primary contextual action `Select products`. + +`Trigger phrases` не являются таблицей: это `react-hook-form` field array с inline validation и keyboard behavior. Поэтому AG Grid для phrases не используется. + +### Empty и loading states + +Edit mode загружает одновременно: + +- `listingQuery.search.productBoost(id)`; +- `listingQuery.search.settings`. + +Товары запрашиваются вложенным полем `productBoost.products` в этом же GraphQL operation. Дополнительный `catalogQuery.products(where: { id: ... })` не нужен. + +До завершения detail query показывается skeleton той же структуры. Если boost не найден, показать `Result`/`Alert` с `Product boost not found` и action `Close`; форму не показывать. + +Если Product недоступен для текущего federated read, API определяет поведение поля `productBoost.products`. UI не должен выполнять второй запрос для попытки восстановить отсутствующую сущность. При расхождении `productsCount` и фактически полученных строк показывается общий data warning и Save блокируется, чтобы не превратить сохранение в неявное удаление товара. + +### Product picker wireframe + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × Select products [Confirm (3)] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ [Search products...] [Filters] │ +│ │ +│ [✓] Product Status │ +│ [✓] Linen sneakers Active │ +│ [ ] Trail runner Active │ +│ [✓] Canvas low tops Draft │ +│ │ +│ 1–20 of 184 [Prev] [Next] │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Используется существующий `ProductPickerModal`; отдельный picker для search не создаётся. `initialSelection` обязан сохраняться между страницами picker. При достижении 50 выбранных товаров остальные checkbox disabled с объясняющим tooltip. + +Сам существующий `ProductPickerModal` также рендерит каталог через AG Grid. Таким образом, Product Boost flow использует два grid-контекста: локальную таблицу уже выбранных товаров в основной modal и существующую pageable/selectable AG Grid таблицу во вложенном picker. + +## Search Synonym Group Modal + +### Основной wireframe + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ × New synonym group [Create] │ +│ / Edit synonym group [Save] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [API error alert — only when present] │ +│ │ +│ ┌─ General ───────────────────────────────────────────────────────────┐ │ +│ │ Name * Locale * │ │ +│ │ [Sneakers terminology_______] [English (en)_______________⌄] │ │ +│ │ │ │ +│ │ Status │ │ +│ │ [● Enabled] Expand matching queries with this group │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Synonyms ──────────────────────────────────────────────────────────┐ │ +│ │ Add terms or phrases with the same meaning. 3 / 20 │ │ +│ │ │ │ +│ │ AG Grid │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ ↕ # Synonym Actions │ │ │ +│ │ ├──────────────────────────────────────────────────────────────┤ │ │ +│ │ │ ↕ 1 [sneakers____________________________] [Remove] │ │ │ +│ │ │ ↕ 2 [trainers____________________________] [Remove] │ │ │ +│ │ │ ↕ 3 [running shoes________________________] [Remove] │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ [+ Add synonym] │ │ +│ │ │ │ +│ │ Preview: sneakers ↔ trainers ↔ running shoes │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### Секции и controls + +| Поле | Control | Правило | +|---|---|---| +| `name` | `Input`, maxLength 128 | После trim от 1 до 128 Unicode code points; служебное название группы. | +| `locale` | searchable `Select` | Определяет normalization profile и область конфликтов. | +| `enabled` | `Switch` | Default `true`. Только enabled group claims synonym values. | +| `values` | editable AG Grid | От 2 до 20 уникальных после normalization значений. Не более 8 searchable tokens в одном value. | + +В create mode AG Grid сразу содержит две пустые editable строки. Удаление disabled, когда осталось две строки. Preview появляется после двух валидных непустых значений и объясняет симметричность группы; не должно быть UI для directional synonyms, потому что API такой модели не поддерживает. + +### Editable AG Grid синонимов + +Секция `Synonyms` должна использовать AG Grid из Admin UI (`AgGridReact`, `useAgGridTheme`, community modules). Это локальный editable grid без server-side pagination, filters и sorting. + +Колонки: + +| Column | Содержимое | Поведение | +|---|---|---| +| row drag | drag handle | переставляет значения и обновляет их array order | +| `#` | позиция `1..n` | вычисляемая, read-only, узкая колонка | +| `Synonym` | editable text cell | custom cell editor/renderer с inline validation state | +| `Actions` | `Remove` | disabled, если в grid осталось две строки | + +Form draft остаётся источником истины. Для строк используются стабильные локальные UUID, а не array index; `getRowId` возвращает этот UUID. `onCellValueChanged`, `onRowDragEnd`, `Add synonym` и `Remove` синхронно обновляют `react-hook-form` values и dirty state. Перед GraphQL mapping локальные UUID удаляются, а `values` формируется в текущем порядке строк. + +Ошибки API вида `values.` отображаются на соответствующей строке и в custom `Synonym` cell renderer. После reorder client validation пересчитывается; API error от предыдущего submit очищается, потому что его index больше не относится к прежней строке. + +Grid имеет `data-testid="synonym-group-values-grid"`, keyboard cell editing, `Enter` для commit и добавления следующей строки из последней заполненной строки, а также ограниченную высоту с внутренним scroll после достижения максимума. Empty state невозможен из-за minimum 2 rows. + +Если enabled group конфликтует с другой активной группой той же локали, API возвращает `SYNONYM_CONFLICT`. Форма сохраняет введённые значения, секция получает error state, а сообщение объясняет, что хотя бы одно нормализованное значение уже используется. UI не должен автоматически выключать текущую или конфликтующую группу. + +## Responsive behavior и accessibility + +- На ширине modal content меньше 640 px `Name` и `Locale` становятся вертикальными, остальные секции не меняют порядок. +- Header action остаётся видимой; body scroll не двигает close/save. +- У каждого input есть видимый label, error связан через `aria-describedby`. +- Повторяющиеся строки имеют accessible names `Trigger phrase 1`, `Synonym 2`. +- Remove/move actions доступны кнопками, имеют tooltip и не зависят только от drag gesture. +- После добавления строки focus переходит в новый input; после удаления — в соседнюю строку. +- После ошибки submit focus переходит к первому ошибочному полю, общий `Alert` использует `role="alert"`. +- `Esc` сначала закрывает верхний picker, затем родительскую модалку по правилам dirty confirmation. + +## GraphQL read integration + +### Detail query для edit mode + +Один query загружает settings и нужную сущность. Для update optimistic token +берётся из `productBoost.version` или `synonymGroup.version`: + +```graphql +query SearchProductBoostEditor($id: ID!) { + listingQuery { + search { + settings { + version + } + productBoost(id: $id) { + id + locale + name + enabled + version + phrases { + phrase + position + } + products { + id + title + isPublished + media { + sortIndex + file { + url + originalName + altText + } + } + } + productsCount + createdAt + updatedAt + } + } + } +} +``` + +```graphql +query SearchSynonymGroupEditor($id: ID!) { + listingQuery { + search { + settings { + version + } + synonymGroup(id: $id) { + id + locale + name + enabled + version + values { + value + position + } + createdAt + updatedAt + } + } + } +} +``` + +Create mode загружает как минимум: + +```graphql +query SearchConfigurationEditorContext { + listingQuery { + search { + settings { + version + } + } + } +} +``` + +`phrases` и `values` перед заполнением формы сортируются по `position`. Product Boost modal читает выбранные товары напрямую из `ApiSearchProductBoost.products`; отдельного catalog query и API-output view model для них нет. Компоненты получают `ApiSearchProductBoost`, `ApiProduct`, `ApiSearchSynonymGroup` и `ApiSearchSettings` напрямую из `@/graphql/types`. + +### Инициализация search settings + +`settingsUpdate` инициализирует только store-level settings. Boost/synonym +mutations не изменяют `SearchSettings.version`; UI по-прежнему блокирует их до +явной инициализации settings. + +Модалки не должны изобретать default search settings. Пока Settings page не предоставляет явный initialize flow, modal показывает blocking alert: + +```text +Search settings must be configured before boosts or synonyms can be created. +[Open search settings] +``` + +Save disabled до появления settings. + +## GraphQL write integration + +### Отдельные resource mutations + +```graphql +mutation SearchProductBoostUpdate($input: SearchProductBoostUpdateInput!) { + listingMutation { + search { + productBoostUpdate(input: $input) { + productBoost { + id + version + } + userErrors { + code + field + message + } + } + } + } +} +``` + +`productBoostCreate`, `productBoostUpdate`, `synonymGroupCreate` и +`synonymGroupUpdate` запускают отдельные workflows. Update передаёт version +самого редактируемого resource; изменение другого boost или synonym не создаёт +ложный conflict. + +### Product boost create + +```json +{ + "input": { + "clientMutationId": "", + "locale": "en", + "name": "Summer footwear", + "enabled": true, + "phrases": ["summer shoes", "shoes for summer"], + "productIds": ["", ""] + } +} +``` + +### Product boost update + +```json +{ + "input": { + "id": "", + "expectedVersion": 3, + "locale": "en", + "name": "Summer footwear", + "enabled": true, + "phrases": ["summer shoes", "shoes for summer"], + "productIds": [""] + } +} +``` + +Create требует `clientMutationId`. Update требует `id` и `expectedVersion` +resource. Оба действия требуют полный набор editable fields; update не является +patch. + +### Synonym group create + +```json +{ + "input": { + "clientMutationId": "", + "locale": "en", + "name": "Sneakers terminology", + "enabled": true, + "values": ["sneakers", "trainers", "running shoes"] + } +} +``` + +### Synonym group update + +```json +{ + "input": { + "id": "", + "expectedVersion": 4, + "locale": "en", + "name": "Sneakers terminology", + "enabled": true, + "values": ["sneakers", "trainers", "running shoes"] + } +} +``` + +### Успешный результат + +Submit считается успешным, когда `userErrors` пуст и payload содержит созданный +или обновлённый resource. + +После успеха: + +1. обновить list query через `refetchQueries`; +2. вызвать `onSaved`; +3. показать success toast; +4. сбросить dirty state и закрыть modal через `forcePop()`. + +## Validation и error mapping + +### Client-side правила + +Клиент повторяет только быстрые детерминированные ограничения: + +| Поле | Client validation | +|---|---| +| `name` | trim, 1–128 Unicode code points | +| boost `phrases` | 1–20 непустых значений | +| boost selected products | 1–50 уникальных ID; mapper формирует API field `productIds` | +| synonym `values` | 2–20 непустых значений | + +Normalization, searchable-token validation, доступность locale, существование products и cross-group conflicts остаются ответственностью API. Клиент не должен реализовывать вторую версию `SearchQueryNormalizer`. + +### Mapping API fields + +API может вернуть paths с batch prefix или script prefix. Error mapper нормализует оба варианта: + +| API field suffix | Form target | +|---|---| +| `name` | `name` | +| `locale` | `locale` | +| `enabled` | `enabled` | +| `phrases` | секция `phrases` | +| `phrases.` | `phrases[index]` | +| `productIds` | секция `products` | +| `productIds.` | конкретная строка products AG Grid | +| `values` | секция `values` | +| `values.` | `values[index]` | +| `expectedVersion` | global conflict alert | + +Примеры возможных prefix: + +```text +operations.productBoosts.0.phrases +operations.synonymGroups.0.values +input.productIds.2 +input.values.1 +``` + +### Concurrency conflict + +При `VERSION_CONFLICT` форма не должна автоматически повторять update с новой +версией: это может перезаписать чужое изменение того же resource. + +Показать blocking alert: + +```text +This resource changed after this form was opened. +[Reload latest data] +``` + +`Reload latest data` повторно загружает detail entity. Если форма dirty, перед +заменой draft требуется подтверждение. После reload Save снова доступен с новой +resource version. + +## Рекомендуемая frontend-структура + +```text +admin/src/domains/discovery/search/ + graphql/ + settings-fragments.ts + settings-mutations.ts + hooks/ + use-search-editor-context.ts + use-update-search-settings.ts + mappers/ + search-errors.mapper.ts + product-boosts/ + graphql/ + fragments.ts + queries.ts + hooks/ + use-product-boost.ts + use-save-product-boost.ts + mappers/ + product-boost-form.mapper.ts + modals/ + product-boost-modal.tsx + schema.ts + synonyms/ + graphql/ + fragments.ts + queries.ts + hooks/ + use-synonym-group.ts + use-save-synonym-group.ts + mappers/ + synonym-group-form.mapper.ts + modals/ + synonym-group-modal.tsx + schema.ts + modals.ts +``` + +Hooks владеют Apollo query/mutation, loading/error state и refetch. Mappers преобразуют только form draft в `ApiSearchProductBoostOperationInput` / `ApiSearchSynonymGroupOperationInput` и API errors в form errors. Modal components не читают вложенные GraphQL payload paths напрямую. + +## Acceptance criteria + +- Create actions и row click открывают правильную Modal Stack modal. +- Create/edit используют одинаковую форму, но корректные title, submit label и mutation action. +- Edit всегда загружает detail entity по `entityId`, а не доверяет snapshot grid row. +- Product picker открывается вторым уровнем Modal Stack и сохраняет selection между страницами. +- Выбранные товары в основной Product Boost modal отображаются через AG Grid, не через самодельный compact list. +- Edit modal получает строки этой таблицы из `SearchProductBoost.products` в detail query и не выполняет отдельный catalog products query. +- При submit `productIds` вычисляются из текущего массива выбранных products. +- Вложенный существующий Product Picker продолжает использовать собственный pageable/selectable AG Grid. +- Synonym values редактируются в локальном editable AG Grid со стабильными row IDs, reorder и inline errors. +- Trigger phrases остаются form field array: их wireframe не является таблицей и AG Grid для них не используется. +- Boost требует 1–20 phrases и 1–50 products; synonym group — 2–20 values. +- Порядок phrases/values отображается по API `position` и отправляется порядком массива. +- Все editable fields отправляются при update. +- `expectedVersion` берётся из `SearchSettings.version`. +- Неинициализированные settings дают понятный blocking state, без неявных defaults. +- Field errors, operation errors, network errors и `VERSION_CONFLICT` имеют отдельные предсказуемые UX states. +- После успеха список обновляется API data, dirty state сбрасывается, modal закрывается. +- Все controls доступны с клавиатуры, а narrow layout не создаёт горизонтальный scroll формы. diff --git a/admin/docs/system-notifications-design.md b/admin/docs/system-notifications-design.md new file mode 100644 index 000000000..a41761a91 --- /dev/null +++ b/admin/docs/system-notifications-design.md @@ -0,0 +1,662 @@ +# Система уведомлений Shopana + +## Назначение документа + +Документ описывает дизайн раздела `Settings → Notifications` по девяти статичным экранам из каталога [`assets/system-notifications`](./assets/system-notifications/). Зафиксированы структура экранов, формы, поля, действия пользователя, переходы, каналы доставки и полный перечень показанных шаблонов. + +> Важно: описание основано только на скриншотах. Обязательность полей, точные правила валидации, подтверждения опасных действий, состояния загрузки и ошибок, клавиатурное управление, адаптивность и фактическое поведение после сохранения требуют проверки в работающем интерфейсе. + +## Карта раздела + +| № | Экран | Назначение | Основные переходы | +|---|---|---|---| +| 1 | Notifications | Точка входа в настройки доставки и группы уведомлений | SMTP, SMS gateway, customer, staff, fulfillment request, webhooks | +| 2 | Customer notifications | Каталог клиентских транзакционных и системных шаблонов | Редактор выбранного шаблона, включение необязательных уведомлений | +| 3 | Staff notifications | Настройка событий для сотрудников и получателей | Редактор события, управление получателем, добавление получателя | +| 4 | Edit notification template — Email | Редактирование темы и HTML-шаблона письма | Email/SMS, Edit/Preview/Variables, Save | +| 5 | Edit notification template — SMS | Редактирование SMS-сообщения | Email/SMS, Save | +| 6 | Webhooks | Список и настройка webhook endpoints | Create webhook | +| 7 | Add webhook | Создание webhook-подписки | Save, Close | +| 8 | SMTP settings | Настройка SMTP и отправителя | Save changes, Send test email | +| 9 | SMS gateway settings | Настройка SMS-провайдера и маршрутизации | Save changes, Test connection | + +## Общие паттерны интерфейса + +- Раздел открывается из левого меню `Settings → Notifications`. +- Списки организованы в карточки; строка с шевроном открывает вложенный экран или редактор. +- Формы открываются как полноэкранный слой поверх затемнённого интерфейса. +- В формах слева в шапке находится закрытие `×`, справа — основное действие сохранения. +- Необязательные уведомления управляются переключателями непосредственно в строках. +- Каналы шаблона представлены вкладками `Email` и `SMS`; кнопка `+` предполагает добавление ещё одного канала, но её точное поведение на скриншотах не показано. +- Серые переключатели на скриншотах визуально выглядят выключенными. Фактическое значение необходимо подтвердить в реализации. + +--- + +## Экран 1. Notifications + +![Notifications](./assets/system-notifications/01-notifications.png) + +### Назначение + +Главная страница системы уведомлений. Объединяет настройки транспортов доставки, каталоги шаблонов и webhooks. + +### Блоки и действия + +#### Message delivery + +1. **SMTP and sender settings** + - Показывает краткое состояние: магазин, адрес отправителя, SMTP host, порт и режим безопасности. + - Нажатие по строке открывает экран `SMTP settings`. + +2. **SMS gateway settings** + - Показывает состояние подключения. + - В показанном состоянии: `Not connected`. + - Нажатие открывает экран `SMS gateway settings`. + +#### Уведомления + +1. **Customer notifications** + - Каталог уведомлений о заказах, аккаунте, платежах, доставке и других клиентских событиях. + - Нажатие открывает экран `Customer notifications`. + +2. **Staff notifications** + - Уведомления сотрудников о новых событиях заказа. + - Нажатие открывает экран `Staff notifications`. + +3. **Fulfillment request notification** + - Шаблон запроса fulfillment-провайдеру после перевода заказа в fulfilled. + - Нажатие открывает редактор шаблона. + +#### Webhooks + +- Описание: отправка XML- или JSON-уведомлений о событиях магазина на URL. +- Нажатие открывает экран `Webhooks`. + +### Состояние экрана + +- SMTP настроен. +- SMS gateway не подключён. +- Количество активных шаблонов, ошибки доставки и история отправок на главной странице не отображаются. + +--- + +## Экран 2. Customer notifications + +![Customer notifications](./assets/system-notifications/02-customer-notifications.png) + +### Назначение + +Каталог шаблонов, отправляемых клиентам и получателям. Шаблоны сгруппированы по предметной области. + +### Поведение строк + +- Нажатие строки или шеврона открывает редактор соответствующего шаблона. +- Если в строке есть переключатель, уведомление можно включить или выключить без входа в редактор. +- Строки без переключателя выглядят как обязательные или системно управляемые, однако это необходимо подтвердить в реализации. + +### Полный перечень клиентских шаблонов + +#### Order processing — обработка заказа + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Order confirmation | Когда клиент размещает заказ | Нет | +| Draft order invoice | При создании invoice на странице draft order | Нет | +| Shipping confirmation | Когда заказ помечен как fulfilled | Нет | + +#### Local pick up — самовывоз + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Ready for local pickup | Когда заказ готов к выдаче | Нет | +| Picked up by customer | Для подтверждения, что клиент забрал заказ | Нет | + +#### Local delivery — локальная доставка + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Order out for local delivery | Когда заказ передан на локальную доставку | Да | +| Order locally delivered | Для подтверждения локальной доставки заказа | Да | +| Order missed local delivery | Когда клиент пропустил локальную доставку | Да | + +#### Gift cards — подарочные карты + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| New gift card | Клиенту или получателю после выпуска либо отправки подарочной карты | Нет | +| Gift card receipt | Клиенту, если к подарочной карте добавлен получатель | Нет | + +#### Store credit — кредит магазина + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Store credit issued | Когда сумма store credit зачислена на аккаунт клиента | Нет | + +#### Order exceptions — исключения и изменения заказа + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Order invoice | Когда по заказу есть непогашенный остаток | Нет | +| Order edited | Когда заказ изменён | Нет | +| Order canceled | Когда клиент отменяет заказ | Нет | +| Order payment receipt | После списания с сохранённого способа оплаты клиента | Нет | +| Order refund | Когда по заказу оформлен возврат средств | Нет | +| Abandoned checkout | Когда клиент не завершил checkout | Нет | +| Order link | Когда клиент запрашивает новую ссылку вместо истёкшей ссылки страницы статуса заказа | Нет | + +#### Payments — платежи + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Payment error | Когда платёж клиента не удалось обработать во время checkout | Нет | +| Pending payment error | Когда отложенный платёж клиента не удалось обработать | Нет | +| Pending payment success | Когда отложенный платёж успешно обработан | Нет | +| Payment reminder | В день срока оплаты или после него для неоплаченного заказа | Нет | + +#### Point of Sale — POS + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| POS abandoned checkout | Когда создан POS draft order, чтобы клиент мог завершить покупку онлайн | Нет | +| POS email to customer | Когда POS cart отправлен клиенту для завершения онлайн | Нет | +| POS and mobile receipt | Когда клиент размещает очный заказ и запрашивает чек | Нет | +| POS exchange receipt | Когда клиент завершает POS-обмен и запрашивает чек | Нет | + +#### Shipping updated — обновления доставки + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Shipping update | При добавлении или обновлении tracking number заказа | Нет | +| Out for delivery | Когда заказ с tracking number передан в доставку | Да | +| Delivered | Когда заказ с tracking number доставлен | Да | + +#### Returns and cancellations — возвраты и отмены + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Return created | При создании возврата, включая label или tracking information | Нет | +| Order-level return label created | При создании return label со страницы заказа; в макете отмечено `US only` | Нет | +| Return request received | Клиенту после получения self-service запроса на возврат | Нет | +| Return request approved | После одобрения запроса на возврат | Нет | +| Return request declined | После отклонения запроса на возврат | Нет | +| Request received | После получения клиентского запроса на возврат или отмену | Нет | +| Cancellation request declined | После отклонения запроса клиента на отмену | Нет | + +#### Accounts and outreach — аккаунт и коммуникации + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Customer account invite | При приглашении клиента создать аккаунт | Нет | +| Customer account welcome | После завершения активации аккаунта | Нет | +| Customer account password reset | Когда клиент запрашивает сброс пароля аккаунта | Нет | +| Customer payment method add request | Когда клиент запрашивает добавление нового способа оплаты | Нет | +| B2B access email | Когда клиент добавлен в компанию | Нет | +| B2B location update payment method | Когда клиент запрашивает обновление способа оплаты B2B location | Нет | +| Contact customer | При обращении к клиенту со страницы orders или customers | Нет | +| Customer email address change confirmation | Когда клиент изменяет email | Нет | + +#### Authentication — аутентификация + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Email verification | Когда клиенту требуется подтвердить email | Нет | +| Login code / magic link | Когда клиент запрашивает одноразовый код или magic link | Нет | +| New login alert | После входа с нового устройства или из нового местоположения | Да | +| Password reset | Когда клиент запрашивает сброс пароля; для password-based accounts | Нет | +| Account deletion confirmation | Когда клиент запрашивает удаление аккаунта и должен подтвердить действие | Нет | + +#### Marketing double opt-in — подтверждение подписки + +| Шаблон | Когда отправляется | Переключатель | +|---|---|---| +| Customer marketing confirmation | Подписчику для подтверждения email- или SMS-подписки | Да | + +### Итог по клиентским шаблонам + +- Всего показано **50** шаблонов. +- У **7** шаблонов есть прямой переключатель: + - Order out for local delivery; + - Order locally delivered; + - Order missed local delivery; + - Out for delivery; + - Delivered; + - New login alert; + - Customer marketing confirmation. + +--- + +## Экран 3. Staff notifications + +![Staff notifications](./assets/system-notifications/03-staff-notifications.png) + +### Назначение + +Настройка уведомлений сотрудников о событиях заказов и списка получателей. + +### Блок Events + +| Шаблон/событие | Описание | Доступные действия | +|---|---|---| +| Store order summary | Периодическая сводка заказов; на экране указано `Every Monday 2:00 PM EDT` | Включить/выключить | +| New order | Когда клиент размещает заказ | Включить/выключить, открыть настройки | +| New change request | Когда клиент запрашивает изменение заказа | Включить/выключить, открыть настройки | +| Sales attribution edited | Для подписчиков order notification после изменения назначенного сотрудника заказа | Включить/выключить, открыть настройки | +| New draft order | Когда клиент отправляет draft order; только владельцу магазина | Включить/выключить, открыть настройки | + +### Блок Recipients + +1. **Существующий получатель** + - В строке показаны имя и email. + - Подпись `All orders` задаёт область событий/заказов. + - Нажатие открывает настройки получателя. + +2. **Add recipient** + - Добавляет нового получателя уведомлений. + - Поля формы добавления на предоставленных экранах не показаны. + +### Не показанные, но необходимые состояния + +- Форма выбора расписания для `Store order summary`. +- Выбор конкретных событий и области заказов для получателя. +- Удаление получателя и подтверждение удаления. +- Обработка дублирующего email, невалидного адреса и отсутствия получателей. + +--- + +## Экраны 4–5. Редактор шаблона уведомления + +### Общая оболочка + +- Заголовок: `Edit notification template`. +- Закрытие: `×`; рядом показана подсказка клавиши `esc`. +- Основное действие: `Save`. +- Вкладки каналов: `Email`, `SMS`, `+`. +- Переключение между Email и SMS должно сохранять несохранённый ввод либо явно предупреждать о его потере; требуемое поведение на скриншотах не показано. + +### Экран 4. Email template + +![Email template](./assets/system-notifications/04-email-template.png) + +#### Форма + +| Элемент | Назначение | +|---|---| +| Subject | Тема письма; поддерживает переменные Handlebars | +| Edit | Режим редактирования HTML | +| Preview | Предпросмотр итогового письма | +| Variables | Справочник и вставка доступных переменных | +| `HTML + Handlebars` | Индикатор формата шаблона | +| Code editor | Редактор HTML с нумерацией строк и подсветкой переменных | +| Save | Сохранение шаблона | + +#### Пример темы + +`Fulfillment request for order {{ order.number }}` + +#### Переменные и конструкции, показанные в макете + +- `{{ customer.first_name }}` +- `{{ order.number }}` +- `{{#each items}} ... {{/each}}` +- `{{ quantity }}` +- `{{ title }}` +- `{{ line_total | money }}` +- `{{ order.total | money }}` + +#### Действия + +1. Переключить канал на Email. +2. Изменить тему. +3. Отредактировать HTML. +4. Открыть `Variables` и вставить допустимую переменную. +5. Открыть `Preview` и проверить результат. +6. Нажать `Save`. +7. Закрыть редактор через `×` или `Esc`. + +#### Ожидаемая валидация + +- непустая тема и тело шаблона; +- допустимый HTML; +- корректный синтаксис Handlebars; +- только разрешённые переменные, helpers и filters; +- понятная ошибка с привязкой к строке редактора; +- предупреждение при закрытии с несохранёнными изменениями. + +Эти правила логичны для формы, но не подтверждены статичным экраном. + +### Экран 5. SMS template + +![SMS template](./assets/system-notifications/05-sms-template.png) + +#### Форма + +| Элемент | Назначение | +|---|---| +| Message | Текст SMS с переменными шаблона | +| Подсказка о 160 символах | Предупреждает, что сообщения длиннее 160 GSM-7 символов будут отправлены несколькими сегментами | +| Save | Сохранение SMS-шаблона | + +#### Пример сообщения + +```handlebars +Hi {{ customer.first_name }}, your order {{ order.number }} is ready for pickup. Track it here: +{{ fulfillment.tracking_url }} +``` + +#### Переменные, показанные в макете + +- `{{ customer.first_name }}` +- `{{ order.number }}` +- `{{ fulfillment.tracking_url }}` + +#### Действия + +1. Переключить канал на SMS. +2. Изменить сообщение. +3. Проверить длину и количество предполагаемых сегментов. +4. Нажать `Save`. + +#### Ожидаемая валидация + +- непустой текст; +- допустимые переменные; +- отображение текущего количества символов и SMS-сегментов; +- предупреждение для Unicode, поскольку лимит и сегментация отличаются от GSM-7; +- предупреждение при закрытии с несохранёнными изменениями. + +Эти правила требуют подтверждения в реализации. + +--- + +## Экран 6. Webhooks + +![Webhooks](./assets/system-notifications/06-webhooks.png) + +### Назначение + +Управление endpoints, получающими подписанные XML- или JSON-уведомления о событиях магазина. + +### Содержимое и действия + +1. Вводный текст объясняет назначение webhooks. +2. **Create webhook** + - Подпись: `Add a new event endpoint`. + - Нажатие открывает форму `Add webhook`. +3. Отображается webhook secret, которым подписываются запросы. + +### Требования безопасности + +- Полный secret не следует постоянно показывать открытым. +- Предпочтительны маска, явное действие `Reveal`, копирование и ротация секрета. +- Просмотр и ротация должны зависеть от разрешений пользователя. +- Значение из дизайн-макета не переносится в этот документ. + +### Не показанные состояния + +- Список уже созданных webhooks. +- Редактирование, отключение, удаление и ротация секрета. +- История попыток доставки, HTTP status, retry и тестовая отправка. +- Пустое состояние отдельно от CTA создания. + +--- + +## Экран 7. Add webhook + +![Add webhook](./assets/system-notifications/07-add-webhook.png) + +### Назначение + +Создание подписки на событие и указание endpoint, который будет получать подписанные запросы. + +### Форма + +| Поле | Тип | Пример на экране | Назначение | +|---|---|---|---| +| Event | Выбор события | `Cart creation` | Событие, запускающее отправку | +| Format | Выбор формата | `JSON` | Формат payload; главный экран также упоминает XML | +| URL | URL input | `https://hooks.example.com/shopana/cart` | Endpoint получателя | +| Webhook API version | Выбор версии | `unstable` | Версия контракта webhook | + +Информационный блок сообщает, что каждый запрос подписывается и подпись нужно проверять с помощью webhook secret магазина. + +### Действия + +- `Save` — создать webhook. +- `×` — закрыть форму без сохранения. + +### Ожидаемая валидация + +- обязательный выбор события, формата и версии API; +- только абсолютный HTTPS URL; +- блокировка loopback/private network endpoints, если они не поддерживаются сознательно; +- проверка синтаксиса и допустимой длины URL; +- обработка дублирующей подписки; +- предупреждение при выборе `unstable`; +- предупреждение при закрытии с несохранёнными изменениями. + +Статичный экран не показывает тексты ошибок и поведение после успешного сохранения. + +--- + +## Экран 8. SMTP settings + +![SMTP settings](./assets/system-notifications/08-smtp-settings.png) + +### Назначение + +Настройка SMTP-подключения, identity отправителя и проверка доставки email. + +### Форма SMTP server + +| Поле | Тип | Назначение | +|---|---|---| +| SMTP host | Text | Host SMTP-сервера | +| Port | Number | Порт подключения | +| Security | Select | Режим безопасности; в примере `STARTTLS` | +| Username | Text | Имя пользователя SMTP | +| Password | Password | Секрет SMTP; после сохранения не показывается | + +Подсказка для пароля: значение хранится безопасно и не отображается после сохранения. + +### Форма Sender identity + +| Поле | Тип | Назначение | +|---|---|---| +| Sender name | Text | Отображаемое имя отправителя | +| Sender email | Email | Адрес в поле From | +| Reply-to email | Email | Адрес для ответов клиентов | + +Подсказка сообщает, что ответы клиентов будут отправляться на `Reply-to email`. + +### Connection check + +- Статус на макете: `Connection verified`. +- Показан адрес, на который можно отправить тестовое письмо. +- `Send test email` запускает тестовую отправку. + +### Действия + +- `Save changes` — сохранить SMTP и sender identity. +- `Send test email` — проверить подключение и доставку. +- `…` в карточках — дополнительное меню; его пункты не показаны. +- `×`/`Esc` — закрыть форму. + +### Ожидаемые правила + +- host обязателен; +- port — целое число в диапазоне `1–65535`; +- согласованность port и security; +- валидные email для sender и reply-to; +- пароль не возвращается клиенту после сохранения; +- тест выполняется только после заполнения обязательных полей; +- отдельные состояния `checking`, `verified`, `failed`; +- ошибки подключения не должны раскрывать пароль или лишние детали инфраструктуры. + +--- + +## Экран 9. SMS gateway settings + +![SMS gateway settings](./assets/system-notifications/09-sms-gateway.png) + +### Назначение + +Подключение SMS-провайдера и настройка sender/routing. + +### Форма SMS provider + +| Поле | Тип | Назначение | +|---|---|---| +| Provider | Select | SMS-провайдер; в макете выбран `Twilio` | +| Account ID / API key | Text | Идентификатор аккаунта или API key | +| Auth token / API secret | Password | Секрет провайдера | + +Подсказка сообщает, что credentials шифруются и могут быть заменены в любое время. + +### Форма Sender and routing + +| Поле | Тип | Назначение | +|---|---|---| +| Sender ID / From number | Text | Номер или Sender ID исходящих сообщений | +| Default region | Select | Регион и телефонный код по умолчанию; в макете `Ukraine (+380)` | +| Messaging service / sender pool | Text, optional | Идентификатор сервиса/пула отправителей провайдера для региональной маршрутизации | + +### Connection check + +- Статус на макете: `Not connected`. +- Подсказка предлагает ввести credentials. +- `Test connection` проверяет доступ к провайдеру. + +### Действия + +- `Save changes` — сохранить настройки. +- `Test connection` — проверить credentials и доступность провайдера. +- `…` в карточках — дополнительное меню; пункты не показаны. +- `×`/`Esc` — закрыть форму. + +### Ожидаемые правила + +- динамические поля и подсказки в зависимости от провайдера; +- обязательные account ID/API key и secret; +- проверка формата From number/Sender ID для выбранного провайдера и страны; +- валидация совместимости региона и sender; +- секрет не возвращается после сохранения; +- отдельные состояния `testing`, `connected`, `failed`; +- понятная расшифровка ошибок авторизации, sender restrictions и региональных ограничений. + +--- + +## Сводный перечень шаблонов + +| Аудитория/назначение | Количество | +|---|---:| +| Customer notifications | 50 | +| Staff notifications | 5 | +| Fulfillment request notification | 1 | +| **Всего показано в дизайне** | **56** | + +### Staff templates + +1. Store order summary. +2. New order. +3. New change request. +4. Sales attribution edited. +5. New draft order. + +### Fulfillment template + +1. Fulfillment request notification. + +Полный перечень 50 клиентских шаблонов приведён в разделе [«Экран 2. Customer notifications»](#экран-2-customer-notifications). + +## Сквозные пользовательские сценарии + +### Настроить email-доставку + +1. Открыть `Notifications`. +2. Выбрать `SMTP and sender settings`. +3. Заполнить SMTP server. +4. Заполнить Sender identity. +5. Сохранить изменения. +6. Дождаться статуса проверки. +7. Отправить тестовое письмо. + +### Настроить SMS-доставку + +1. Открыть `Notifications`. +2. Выбрать `SMS gateway settings`. +3. Выбрать провайдера. +4. Ввести credentials. +5. Настроить sender и регион. +6. Сохранить изменения. +7. Проверить подключение. + +### Изменить клиентский шаблон + +1. Открыть `Customer notifications`. +2. Найти категорию и событие. +3. При необходимости включить событие переключателем. +4. Открыть строку шаблона. +5. Изменить Email и/или SMS. +6. Проверить preview и переменные. +7. Сохранить. + +### Настроить уведомления сотрудников + +1. Открыть `Staff notifications`. +2. Включить нужные события. +3. Открыть событие и уточнить его настройки. +4. Открыть существующего получателя или добавить нового. +5. Назначить область заказов и события. +6. Сохранить. + +### Создать webhook + +1. Открыть `Webhooks`. +2. Выбрать `Create webhook`. +3. Выбрать event, format и API version. +4. Указать endpoint URL. +5. Сохранить webhook. +6. На стороне получателя проверять подпись запроса. + +## Краткая оценка дизайна + +### Сильные стороны + +- Настройки транспорта отделены от шаблонов и webhook-интеграций. +- Каталог из 50 клиентских шаблонов разбит на понятные предметные группы. +- Шаблоны Email и SMS редактируются в одной оболочке. +- SMTP и SMS имеют отдельную проверку подключения. +- Редактор явно показывает технологию шаблона и доступ к variables/preview. +- Для секретов SMTP и SMS предусмотрены пояснения о безопасном хранении. + +### UX-риски + +1. **Длинный каталог без поиска и фильтров.** На странице 50 клиентских шаблонов; поиск по названию, событию и каналу сократит время настройки. +2. **Неочевидная разница между строками с переключателем и без него.** Нужна явная легенда: какие уведомления обязательны, а какие можно отключить. +3. **Нет видимого состояния канала у шаблона.** В каталоге полезно показывать badges `Email`, `SMS`, `Disabled`, `Customized`. +4. **Webhook secret показан открыто.** Это создаёт риск случайного раскрытия при демонстрации экрана. +5. **Нет видимого счётчика SMS.** Подсказка о 160 GSM-7 символах есть, но до отправки нужны live character/segment count и предупреждение о Unicode. +6. **Не показано управление несохранёнными изменениями.** Закрытие, смена канала или вкладки должны предупреждать о потере правок. +7. **Статус подключения не объясняет свежесть проверки.** Полезно показывать время последней успешной проверки и причину последней ошибки. + +### Риски доступности + +- Серые переключатели имеют слабый визуальный контраст; состояние не должно передаваться только цветом. +- Иконки `×`, `…`, `+` и шевроны нуждаются в доступных названиях и достаточной области нажатия. +- Визуальная подсветка активной вкладки должна сопровождаться корректными tab semantics и фокусом. +- Code editor должен поддерживать клавиатуру, читаемую фокусную рамку и альтернативный режим для screen reader. +- Статусы `verified/not connected` должны объявляться assistive technologies, особенно после асинхронной проверки. +- Ошибки форм должны связываться с конкретными полями и содержать текст, а не только цвет. + +### Что проверить в работающем интерфейсе + +- клавиатурную навигацию, порядок фокуса и `Esc`; +- labels, roles, accessible names и объявления статусов; +- контраст текста, переключателей, границ и focus states; +- адаптивный reflow и масштабирование до 200–400%; +- loading, success, error, empty и permission-denied states; +- сохранение черновика при переключении Email/SMS; +- серверную валидацию шаблонов, URL и credentials; +- permissions и аудит действий с секретами; +- retry/idempotency для webhook и тестовых отправок. + diff --git a/admin/eslint.config.mjs b/admin/eslint.config.mjs index 35b581e69..c215b4769 100644 --- a/admin/eslint.config.mjs +++ b/admin/eslint.config.mjs @@ -9,6 +9,7 @@ const eslintConfig = defineConfig([ globalIgnores([ // Default ignores of eslint-config-next: ".next/**", + ".next-e2e-*/**", "out/**", "build/**", "next-env.d.ts", @@ -17,6 +18,19 @@ const eslintConfig = defineConfig([ rules: { "react-hooks/preserve-manual-memoization": "off", "@next/next/no-img-element": "off", + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@shopana/admin-app-sdk"], + allowTypeImports: true, + message: + "The internal Admin Apps SDK alias is compile-time only. Use import type.", + }, + ], + }, + ], }, }, ]); diff --git a/admin/next.config.ts b/admin/next.config.ts index 069351008..f3f967305 100644 --- a/admin/next.config.ts +++ b/admin/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + distDir: process.env.NEXT_DIST_DIR ?? ".next", transpilePackages: ['antd', '@ant-design', 'rc-util', 'rc-pagination', 'rc-picker', '@rc-component'], async redirects() { return [ diff --git a/admin/package.json b/admin/package.json index 5444e8efc..0a79e964e 100644 --- a/admin/package.json +++ b/admin/package.json @@ -16,8 +16,11 @@ "@ant-design/nextjs-registry": "^1.3.0", "@apollo/client": "^4.0.12", "@apollo/experimental-nextjs-app-support": "^0.14.4", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/view": "^6.43.7", + "@dnd-kit/core": "6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@editorjs/delimiter": "^1.4.2", "@editorjs/editorjs": "^2.31.0", @@ -26,21 +29,25 @@ "@editorjs/paragraph": "^2.11.7", "@editorjs/quote": "^2.7.6", "@hookform/resolvers": "^5.2.2", + "@module-federation/enhanced": "2.8.0", "@types/apollo-upload-client": "^19.0.0", "@types/dagre": "^0.7.53", "@types/react-infinite-scroller": "^1.2.5", + "@uiw/react-codemirror": "^4.25.11", "@xyflow/react": "^12.10.0", "ag-grid-community": "^34.3.1", "ag-grid-react": "^34.3.1", "antd": "^6.0.1", "antd-style": "^3.7.1", "apollo-upload-client": "^19.0.0", + "classnames": "^2.5.1", "dayjs": "^1.11.19", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "editorjs-html": "^4.0.5", "elkjs": "^0.11.0", "html-to-text": "^9.0.5", + "lodash": "^4.17.21", "next": "16.0.6", "path-to-regexp": "^8.3.0", "react": "19.2.0", @@ -50,6 +57,8 @@ "react-image-crop": "^11.0.10", "react-infinite-scroller": "^1.2.6", "react-player": "^3.4.0", + "react-tiny-virtual-list": "^2.2.0", + "react-virtualized-auto-sizer": "^1.0.24", "transliteration": "^2.6.0", "zod": "^4.3.5", "zustand": "^5.0.9" @@ -61,6 +70,7 @@ "@graphql-codegen/typescript-operations": "^4.6.1", "@graphql-codegen/typescript-react-apollo": "^4.4.0", "@types/html-to-text": "^9.0.4", + "@types/lodash": "^4.14.200", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/admin/public/app-icons/default.svg b/admin/public/app-icons/default.svg new file mode 100644 index 000000000..0a1d1d8fa --- /dev/null +++ b/admin/public/app-icons/default.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/admin/public/app-icons/headless.svg b/admin/public/app-icons/headless.svg new file mode 100644 index 000000000..5123928bc --- /dev/null +++ b/admin/public/app-icons/headless.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/admin/public/app-icons/hello-world.svg b/admin/public/app-icons/hello-world.svg new file mode 100644 index 000000000..83757e121 --- /dev/null +++ b/admin/public/app-icons/hello-world.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/admin/public/app-icons/mailer.svg b/admin/public/app-icons/mailer.svg new file mode 100644 index 000000000..5a1619eae --- /dev/null +++ b/admin/public/app-icons/mailer.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/admin/public/app-icons/online-store.svg b/admin/public/app-icons/online-store.svg new file mode 100644 index 000000000..21ced09d6 --- /dev/null +++ b/admin/public/app-icons/online-store.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/admin/schema.graphql b/admin/schema.graphql index 8ee75db28..191190875 100644 --- a/admin/schema.graphql +++ b/admin/schema.graphql @@ -86,28 +86,29 @@ schema enum join__Graph { APPS_ADMIN @join__graph(name: "apps-admin", url: "http://localhost:10001/graphql") + CATALOG_ADMIN @join__graph(name: "catalog-admin", url: "http://localhost:10011/graphql") IAM_ADMIN @join__graph(name: "iam-admin", url: "http://localhost:10010/graphql") - INVENTORY_ADMIN @join__graph(name: "inventory-admin", url: "http://localhost:10005/graphql") + LISTING_ADMIN @join__graph(name: "listing-admin", url: "http://localhost:10012/graphql") MEDIA_ADMIN @join__graph(name: "media-admin", url: "http://localhost:10007/graphql") ORDERS_ADMIN @join__graph(name: "orders-admin", url: "http://localhost:10004/graphql") PROJECT_ADMIN @join__graph(name: "project-admin", url: "http://localhost:10009/graphql") } -scalar JSON @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) +scalar JSON @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) -scalar TransportOptions @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) +scalar TransportOptions @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) """ ISO 8601 date-time string """ -scalar DateTime @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) +scalar DateTime @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) """ Valid email address """ -scalar Email @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) +scalar Email @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) -scalar BigInt @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) +scalar BigInt @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) scalar Upload @join__type(graph: MEDIA_ADMIN) @@ -124,7 +125,7 @@ type App @join__type(graph: APPS_ADMIN) { type InstalledApp @join__type(graph: APPS_ADMIN) { id: String! - projectID: String! + storeID: String! appCode: String! domain: String! baseURL: String! @@ -154,9 +155,21 @@ type AppsMutation @join__type(graph: APPS_ADMIN) { uninstall(code: String!) : Boolean! } -type Query @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { +type Query @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { appsQuery: AppsQuery! @join__field(graph: APPS_ADMIN) """ + Catalog query namespace for product, variant, category, and collection operations + """ + catalogQuery: CatalogQuery! @join__field(graph: CATALOG_ADMIN) + """ + Widget query namespace for dashboard widgets + """ + widgetQuery: WidgetQuery! @join__field(graph: CATALOG_ADMIN) + """ + Inventory query namespace for warehouse, stock, and inventory item operations + """ + inventoryQuery: InventoryQuery! @join__field(graph: CATALOG_ADMIN) + """ User management queries. """ userQuery: UserQuery! @join__field(graph: IAM_ADMIN) @@ -164,7 +177,10 @@ type Query @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join_ Organization queries namespace. """ organizationQuery: OrganizationQuery! @join__field(graph: IAM_ADMIN) - inventoryQuery: InventoryQuery! @join__field(graph: INVENTORY_ADMIN) + """ + Listing query namespace. + """ + listingQuery: ListingQuery! @join__field(graph: LISTING_ADMIN) mediaQuery: MediaQuery! @join__field(graph: MEDIA_ADMIN) orderQuery: OrderQuery! @join__field(graph: ORDERS_ADMIN) """ @@ -173,9 +189,17 @@ type Query @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join_ storeQuery: StoreQuery! @join__field(graph: PROJECT_ADMIN) } -type Mutation @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { +type Mutation @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { appsMutation: AppsMutation! @join__field(graph: APPS_ADMIN) """ + Catalog mutation namespace for product, variant, category, and collection operations + """ + catalogMutation: CatalogMutation! @join__field(graph: CATALOG_ADMIN) + """ + Inventory mutation namespace for warehouse, stock, and inventory item operations + """ + inventoryMutation: InventoryMutation! @join__field(graph: CATALOG_ADMIN) + """ User management mutations. """ userMutation: UserMutation! @join__field(graph: IAM_ADMIN) @@ -191,7 +215,10 @@ type Mutation @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @jo Organization management mutations. """ organizationMutation: OrganizationMutation! @join__field(graph: IAM_ADMIN) - inventoryMutation: InventoryMutation! @join__field(graph: INVENTORY_ADMIN) + """ + Listing mutation namespace. + """ + listingMutation: ListingMutation! @join__field(graph: LISTING_ADMIN) mediaMutation: MediaMutation! @join__field(graph: MEDIA_ADMIN) orderMutation: OrderMutation! @join__field(graph: ORDERS_ADMIN) """ @@ -200,1497 +227,1405 @@ type Mutation @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @jo storeMutation: StoreMutation! @join__field(graph: PROJECT_ADMIN) } -""" -A generic user error type for mutation responses. -""" -type GenericUserError implements UserError @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: PROJECT_ADMIN) @join__implements(graph: IAM_ADMIN, interface: "UserError") @join__implements(graph: INVENTORY_ADMIN, interface: "UserError") @join__implements(graph: MEDIA_ADMIN, interface: "UserError") @join__implements(graph: PROJECT_ADMIN, interface: "UserError") { +type CatalogQuery @join__type(graph: CATALOG_ADMIN) { """ - Human-readable error message + Get a node by its global ID """ - message: String! + node(id: ID!) : Node """ - Path to the field that caused the error + Get multiple nodes by their global IDs """ - field: [String!] + nodes(ids: [ID!]!) : [Node]! """ - Machine-readable error code + Get a product by ID """ - code: String -} - -""" -Authentication tokens. -""" -type AuthTokenPayload @join__type(graph: IAM_ADMIN) { + product(id: ID!) : Product """ - Access token for API requests. + Get products with Relay-style pagination """ - accessToken: String! + products( + first: Int + after: String + last: Int + before: String + """ + Filter conditions + """ + where: ProductWhereInput + """ + Sort order + """ + orderBy: [ProductOrderByInput!] + """ + Additional query metadata and scope filters + """ + meta: ProductProductsMetaInput + ): ProductConnection! """ - Refresh token for obtaining new access tokens. + Get a bundle by Product global ID. The product must have kind = BUNDLE. """ - refreshToken: String! + bundle(id: ID!) : Bundle """ - Expiration time in seconds. + Get bundles with Relay-style pagination. """ - expiresIn: Int! -} - -type UserQuery @join__type(graph: IAM_ADMIN) { + bundles( + first: Int + after: String + last: Int + before: String + where: BundleWhereInput + orderBy: [BundleOrderByInput!] + meta: BundleBundlesMetaInput + ): BundleConnection! """ - Get current authenticated admin user + Get a variant by ID """ - current: User + variant(id: ID!) : Variant """ - Check authorization for current user. - Used for server-side permission checks. - For client-side checks, use project.roles + user.role. + Get variants with Relay-style pagination """ - authorize(input: AuthorizeInput!) : AuthorizePayload! -} - -type UserMutation @join__type(graph: IAM_ADMIN) { - userUpdateProfile(input: UserUpdateProfileInput!) : UserUpdateProfilePayload! - userUpdateEmail(input: UserUpdateEmailInput!) : UserUpdateEmailPayload! - userUpdatePassword(input: UserUpdatePasswordInput!) : UserUpdatePasswordPayload! -} - -type AuthMutation @join__type(graph: IAM_ADMIN) { - signUp(input: UserSignUpInput!) : UserSignUpPayload! - signIn(input: UserSignInInput!) : UserSignInPayload! - signOut(input: UserSignOutInput!) : UserSignOutPayload! - tokenRefresh(input: UserTokenRefreshInput!) : UserTokenRefreshPayload! -} - -""" -Role mutations. -""" -type RoleMutation @join__type(graph: IAM_ADMIN) { + variants( + first: Int + after: String + last: Int + before: String + """ + Filter conditions + """ + where: VariantWhereInput + """ + Sort order + """ + orderBy: [VariantOrderByInput!] + ): VariantConnection! """ - Create custom role. - Requires: project:admin permission. + Get a vendor by ID """ - roleCreate(input: RoleCreateInput!) : RoleCreatePayload! + vendor(id: ID!) : Vendor """ - Update role. - Requires: project:admin permission. - System roles cannot be modified. + Get vendors with Relay-style pagination """ - roleUpdate(input: RoleUpdateInput!) : RoleUpdatePayload! + vendors( + first: Int + after: String + last: Int + before: String + """ + Filter conditions + """ + where: VendorWhereInput + """ + Sort order + """ + orderBy: [VendorOrderByInput!] + ): VendorConnection! """ - Delete custom role. - Requires: project:admin permission. - System roles cannot be deleted. - Roles with assigned users cannot be deleted. + Get bulk update job by ID. """ - roleDelete(input: RoleDeleteInput!) : RoleDeletePayload! -} - -""" -Member with role assignment. -Used for both org-level (domain = "org") and store-level (domain = "store:uuid"). -""" -type Member @join__type(graph: IAM_ADMIN, key: "id") { + productBulkUpdateJob(jobId: ID!) : ProductBulkUpdateJob """ - Unique identifier. + Get product bulk update jobs for the current store. + Defaults to active jobs when statusFilter is omitted. """ - id: ID! + productBulkUpdateJobs(first: Int, after: String, statusFilter: [BulkUpdateJobStatus!]) : ProductBulkUpdateJobConnection! """ - User reference. + Get a category by ID """ - user: User! + category(id: ID!) : Category """ - Role name. + Get a collection by ID """ - role: String! + collection(id: ID!) : Collection """ - Whether this member is the organization owner. - Owner bypasses all authorization checks within the organization. - Only applicable for org-level membership (domain = "org"). + Get a collection by its handle """ - isOwner: Boolean! + collectionByHandle(handle: String!) : Collection """ - When access was granted. + Get collections with Relay-style pagination """ - grantedAt: DateTime! + collections(first: Int, after: String, last: Int, before: String) : CollectionConnection! """ - User who granted access. + Preview count of products matching collection rules """ - grantedBy: User + collectionRulesPreviewCount(rules: [CollectionRuleInput!]!) : Int! + """ + Get categories with Relay-style pagination + """ + categories( + first: Int + after: String + last: Int + before: String + where: CategoryWhereInput + orderBy: [CategoryOrderByInput!] + meta: CategoryCategoriesMetaInput + ): CategoryConnection! + """ + Get a tag by ID + """ + tag(id: ID!) : Tag + """ + Get tags with Relay-style pagination + """ + tags( + first: Int + after: String + last: Int + before: String + """ + Filter conditions + """ + where: TagWhereInput + """ + Sort order + """ + orderBy: [TagOrderByInput!] + ): TagConnection! } """ -Membership — universal container for members and roles. -Used for both Organization and Store. -Domain determines context: orgId for org-level, storeId for store-level. +Widget query namespace for dashboard widgets. """ -type Membership @join__type(graph: IAM_ADMIN, key: "domain organizationId") @join__type(graph: PROJECT_ADMIN, key: "domain organizationId", extension: true) { +type WidgetQuery @join__type(graph: CATALOG_ADMIN) { """ - Domain identifier ("org" for organization, or "store:uuid"). + Get pricing widget data for a variant. """ - domain: String! + pricing(input: PricingWidgetInput!) : PricingWidgetPayload! """ - Organization ID (required for casbin queries). + Get inventory widget data for a product. + Returns aggregated inventory metrics across all variants. """ - organizationId: ID! + inventory(productId: ID!) : ProductInventoryWidget +} + +type InventoryQuery @join__type(graph: CATALOG_ADMIN) { """ - All roles available in this organization. + Get a node by its global ID """ - roles: [Role!]! @join__field(graph: IAM_ADMIN) + node(id: ID!) : Node """ - All members with access to this domain. + Get multiple nodes by their global IDs """ - members: [Member!]! @join__field(graph: IAM_ADMIN) + nodes(ids: [ID!]!) : [Node]! """ - Available resources for role editor (org-level only). + Get a warehouse by ID """ - availableResources: [ResourceDefinition!] @join__field(graph: IAM_ADMIN) -} - -""" -Organization - top level entity for multi-tenancy. -Users belong to organizations, organizations contain stores. -""" -type Organization implements Node @join__type(graph: IAM_ADMIN, key: "id") @join__type(graph: PROJECT_ADMIN, key: "id", extension: true) @join__implements(graph: IAM_ADMIN, interface: "Node") { + warehouse(id: ID!) : Warehouse """ - Unique identifier. + Get all warehouses """ - id: ID! + warehouses( + first: Int + after: String + last: Int + before: String + """ + Filter conditions + """ + where: WarehouseWhereInput + """ + Sort order + """ + orderBy: [WarehouseOrderByInput!] + ): WarehouseConnection! """ - URL-friendly unique identifier. + Get an inventory item by ID """ - name: String! @join__field(graph: IAM_ADMIN) + inventoryItem(id: ID!) : InventoryItem """ - Display name (e.g., "Acme Corp"). + Get an inventory item by variant ID """ - displayName: String! @join__field(graph: IAM_ADMIN) + inventoryItemByVariant(variantId: ID!) : InventoryItem """ - Membership info (members + roles). Domain = orgId. + Get inventory items with Relay-style pagination """ - membership: Membership! @join__field(graph: IAM_ADMIN) + inventoryItems( + first: Int + after: String + last: Int + before: String + where: InventoryItemWhereInput + orderBy: [InventoryItemOrderByInput!] + meta: InventoryItemInventoryItemsMetaInput + ): InventoryItemConnection! """ - Timestamp when the organization was created. + Get variants that can still be assigned to the selected warehouse """ - createdAt: DateTime! @join__field(graph: IAM_ADMIN) + warehouseAssignableVariants( + warehouseId: ID! + first: Int + after: String + last: Int + before: String + where: WarehouseAssignableVariantWhereInput + orderBy: [WarehouseAssignableVariantOrderByInput!] + ): VariantConnection! +} + +type InventoryMutation @join__type(graph: CATALOG_ADMIN) { """ - Timestamp when the organization was last updated. + Update inventory item: stock, SKU, and cost. """ - updatedAt: DateTime @join__field(graph: IAM_ADMIN) + inventoryItemUpdate(input: InventoryItemUpdateInput!) : InventoryItemUpdatePayload! + warehouseCreate(input: WarehouseCreateInput!) : WarehouseCreatePayload! + warehouseUpdate(input: WarehouseUpdateInput!) : WarehouseUpdatePayload! + warehouseDelete(input: WarehouseDeleteInput!) : WarehouseDeletePayload! + warehouseStockCreate(input: WarehouseStockCreateInput!) : WarehouseStockCreatePayload! + warehouseStockDelete(input: WarehouseStockDeleteInput!) : WarehouseStockDeletePayload! } -""" -A connection to a list of Organization items. -""" -type OrganizationConnection @join__type(graph: IAM_ADMIN) { +type CatalogMutation @join__type(graph: CATALOG_ADMIN) { """ - A list of edges. + Create a new vendor """ - edges: [OrganizationEdge!]! + vendorCreate(input: VendorCreateInput!) : VendorCreatePayload! """ - Information to aid in pagination. + Create a new product """ - pageInfo: PageInfo! + productCreate(input: ProductCreateInput!) : ProductCreatePayload! """ - The total number of organizations. + Delete an existing product """ - totalCount: Int! -} - -""" -An edge in an Organization connection. -""" -type OrganizationEdge @join__type(graph: IAM_ADMIN) { + productDelete(input: ProductDeleteInput!) : ProductDeletePayload! """ - The item at the end of the edge. + Unified product update with optimistic locking. + Supports product and variant updates in a single request. """ - node: Organization! + productUpdate( + """ + The product ID to update. + """ + productId: ID! + """ + Expected revision for optimistic locking. If provided, fails if product was modified. + """ + expectedRevision: Int + """ + Product-level operations. + """ + operations: ProductUpdateInput + ): ProductUpdatePayload! """ - A cursor for use in pagination. - """ - cursor: String! -} - -type OrganizationCreatePayload @join__type(graph: IAM_ADMIN) { - organization: Organization - userErrors: [GenericUserError!]! -} - -type OrganizationUpdatePayload @join__type(graph: IAM_ADMIN) { - organization: Organization - userErrors: [GenericUserError!]! -} - -type OrganizationDeletePayload @join__type(graph: IAM_ADMIN) { - deletedOrganizationId: ID - userErrors: [GenericUserError!]! -} - -type MemberInvitePayload @join__type(graph: IAM_ADMIN) { - member: Member - userErrors: [GenericUserError!]! -} - -type MemberRemovePayload @join__type(graph: IAM_ADMIN) { - removedMemberId: ID - userErrors: [GenericUserError!]! -} - -type MemberRoleChangePayload @join__type(graph: IAM_ADMIN) { - member: Member - userErrors: [GenericUserError!]! -} - -type MemberAccessRemovePayload @join__type(graph: IAM_ADMIN) { - success: Boolean! - userErrors: [GenericUserError!]! -} - -type OwnershipTransferPayload @join__type(graph: IAM_ADMIN) { - """ - Whether the transfer was successful. - """ - success: Boolean! - userErrors: [GenericUserError!]! -} - -""" -Organization queries. -""" -type OrganizationQuery @join__type(graph: IAM_ADMIN) { + Create a new bundle sellable item. """ - Get organization by ID or name (if user has access). - Provide either id or name, not both. - """ - organization(id: ID, name: String) : Organization + bundleCreate(input: BundleCreateInput!) : BundleCreatePayload! """ - Get all organizations the current user has access to with cursor pagination. - Returns empty connection if not authenticated. + Unified bundle update with optimistic locking. """ - organizations( - """ - Returns the first n items - """ - first: Int - """ - Returns items after this cursor - """ - after: String - """ - Returns the last n items - """ - last: Int + bundleUpdate( """ - Returns items before this cursor + The bundle ID to update. """ - before: String + bundleId: ID! """ - Filter conditions + Expected revision for optimistic locking. """ - where: OrganizationWhereInput + expectedRevision: Int! """ - Sort order + Bundle-level operations. """ - orderBy: [OrganizationOrderByInput!] - ): OrganizationConnection! -} - -""" -Organization mutations. -""" -type OrganizationMutation @join__type(graph: IAM_ADMIN) { + operations: BundleUpdateInput + ): BundleUpdatePayload! """ - Create a new organization. - Current user becomes the owner. + Create one bundle configuration. """ - organizationCreate(input: OrganizationCreateInput!) : OrganizationCreatePayload! + bundleConfigurationCreate(input: BundleConfigurationCreateInput!) : BundleConfigurationPayload! """ - Update organization. - Requires: org admin or owner. + Update configuration metadata. """ - organizationUpdate(input: OrganizationUpdateInput!) : OrganizationUpdatePayload! + bundleConfigurationUpdate(input: BundleConfigurationUpdateInput!) : BundleConfigurationPayload! """ - Delete organization. Requires: org owner only. + Delete one bundle configuration with optimistic locking. """ - organizationDelete(id: ID!) : OrganizationDeletePayload! + bundleConfigurationDelete(input: BundleConfigurationDeleteInput!) : BundleConfigurationDeletePayload! """ - Transfer organization ownership to another admin. - Only the current owner can transfer ownership. - New owner must have admin role in the organization. - Previous owner retains admin role. + Sync all groups/items for one bundle configuration. """ - ownershipTransfer(input: OwnershipTransferInput!) : OwnershipTransferPayload! + bundleGroupsSync(input: BundleGroupsSyncInput!) : BundleGroupsSyncPayload! """ - Invite member to organization with role assignments. + Sync all reusable pricing templates for one bundle configuration. """ - memberInvite(input: MemberInviteInput!) : MemberInvitePayload! + bundlePricingTemplatesSync(input: BundlePricingTemplatesSyncInput!) : BundlePricingTemplatesSyncPayload! """ - Remove member from organization. - Requires: org admin or owner. - Cannot remove owner (transfer ownership first). + Sync all dependency rules for one bundle configuration. """ - memberRemove(input: MemberRemoveInput!) : MemberRemovePayload! + bundleDependencyRulesSync(input: BundleDependencyRulesSyncInput!) : BundleDependencyRulesSyncPayload! """ - Change role for a member in specific domain. - Owner cannot be demoted. + Create a new variant for a product """ - memberRoleChange(input: MemberRoleChangeInput!) : MemberRoleChangePayload! + variantCreate(input: VariantCreateInput!) : VariantCreatePayload! """ - Remove member's access from domain. + Delete a variant """ - memberAccessRemove(input: MemberAccessRemoveInput!) : MemberAccessRemovePayload! -} - -""" -Information about pagination in a connection. -""" -type PageInfo @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) { + variantDelete(input: VariantDeleteInput!) : VariantDeletePayload! """ - When paginating forwards, are there more items? + Update variant pricing information """ - hasNextPage: Boolean! + variantUpdatePricing(input: VariantUpdatePricingInput!) : VariantUpdatePricingPayload! """ - When paginating backwards, are there more items? + Update variant option values """ - hasPreviousPage: Boolean! + variantUpdateOptions(input: VariantUpdateOptionsInput!) : VariantUpdateOptionsPayload! """ - When paginating backwards, the cursor to continue. + Create a new product option (e.g., Size, Color) """ - startCursor: String + productOptionCreate(input: ProductOptionCreateInput!) : ProductOptionCreatePayload! """ - When paginating forwards, the cursor to continue. + Update an existing product option """ - endCursor: String -} - -""" -Role with permissions - universal, can be assigned at any level. -""" -type Role @join__type(graph: IAM_ADMIN, key: "id") { + productOptionUpdate(input: ProductOptionUpdateInput!) : ProductOptionUpdatePayload! """ - Unique identifier. + Delete a product option """ - id: ID! + productOptionDelete(input: ProductOptionDeleteInput!) : ProductOptionDeletePayload! """ - Domain scope for this role. - - "org" = organization-level role - - "store:{uuid}" = store-specific role + Sync all product options. This is a complete replace operation. + Options not in the input list will be deleted. + Does NOT affect variants - only option definitions are synced. """ - domain: String! + productOptionsSync(input: ProductOptionsSyncInput!) : ProductOptionsSyncPayload! """ - Unique role name within organization (e.g.: admin, manager, viewer). + Create a new product feature """ - name: String! + productFeatureCreate(input: ProductFeatureCreateInput!) : ProductFeatureCreatePayload! """ - Human-readable display name. + Update an existing product feature """ - displayName: String! + productFeatureUpdate(input: ProductFeatureUpdateInput!) : ProductFeatureUpdatePayload! """ - Role description. + Delete a product feature """ - description: String + productFeatureDelete(input: ProductFeatureDeleteInput!) : ProductFeatureDeletePayload! """ - System role cannot be deleted or modified. + Sync all product features (complete replace operation) """ - isSystem: Boolean! + productFeaturesSync(input: ProductFeaturesSyncInput!) : ProductFeaturesSyncPayload! """ - Role permissions. + Update media attachments for a variant """ - permissions: [RolePermission!]! + variantUpdateMedia(input: VariantUpdateMediaInput!) : VariantUpdateMediaPayload! """ - Role creation date. + Create a new category """ - createdAt: DateTime + categoryCreate(input: CategoryCreateInput!) : CategoryCreatePayload! """ - Role last update date. + Unified category update with optimistic locking. """ - updatedAt: DateTime + categoryUpdate(categoryId: ID!, expectedRevision: Int, operations: CategoryUpdateInput) : CategoryUpdatePayload! + """ + Move a category to a new parent or position + """ + categoryMove(input: CategoryMoveInput!) : CategoryMovePayload! + """ + Rebalance category tree positions + """ + categoryRebalance(input: CategoryRebalanceInput!) : CategoryRebalancePayload! + """ + Delete a category + """ + categoryDelete(input: CategoryDeleteInput!) : CategoryDeletePayload! + """ + Create a new collection + """ + collectionCreate(input: CollectionCreateInput!) : CollectionCreatePayload! + """ + Update an existing collection + """ + collectionUpdate(input: CollectionUpdateInput!) : CollectionUpdatePayload! + """ + Delete a collection + """ + collectionDelete(input: CollectionDeleteInput!) : CollectionDeletePayload! + """ + Add products to a collection + """ + collectionAddProducts(input: CollectionAddProductsInput!) : CollectionAddProductsPayload! + """ + Remove products from a collection + """ + collectionRemoveProducts(input: CollectionRemoveProductsInput!) : CollectionRemoveProductsPayload! + """ + Move a product within a collection + """ + collectionMoveProduct(input: CollectionMoveProductInput!) : CollectionMoveProductPayload! + """ + Update collection rules for automatic product inclusion + """ + collectionUpdateRules(input: CollectionUpdateRulesInput!) : CollectionUpdateRulesPayload! + """ + Create a new tag + """ + tagCreate(input: TagCreateInput!) : TagCreatePayload! + """ + Update an existing tag + """ + tagUpdate(input: TagUpdateInput!) : TagUpdatePayload! + """ + Delete a tag + """ + tagDelete(input: TagDeleteInput!) : TagDeletePayload! + """ + Start async bulk update. + Requires X-Idempotency-Key header. + """ + productBulkUpdate(input: ProductBulkUpdateInput!) : ProductBulkUpdatePayload! } """ -Role permission - access to resource with specific actions. +Result of bulk update start/cancel. """ -type RolePermission @join__type(graph: IAM_ADMIN) { +type ProductBulkUpdatePayload @join__type(graph: CATALOG_ADMIN) { """ - Resource name (e.g.: org.profile, store.members). + Created or updated job (null on validation error). """ - resource: String! + job: ProductBulkUpdateJob """ - Allowed actions (e.g.: create, read, update, delete). + Validation/execution errors. """ - actions: [String!]! + userErrors: [BulkUpdateUserError!]! } """ -Resource definition for role editor UI. +Bulk update job with progress. """ -type ResourceDefinition @join__type(graph: IAM_ADMIN) { +type ProductBulkUpdateJob @join__type(graph: CATALOG_ADMIN) { """ - Resource name (product, order, etc.). + Job ID. """ - name: String! + id: ID! """ - Available actions for resource. + Current status. """ - actions: [String!]! + status: BulkUpdateJobStatus! """ - Display name. + When created. """ - displayName: String + createdAt: DateTime! """ - Resource description. + When started running. """ - description: String -} - -type RoleCreatePayload @join__type(graph: IAM_ADMIN) { - role: Role - userErrors: [GenericUserError!]! + startedAt: DateTime + """ + When finished. + """ + finishedAt: DateTime + """ + Total products in batch. + """ + totalProducts: Int! + """ + Progress computed from items. + """ + progress: BulkUpdateJobProgress! + """ + Items with pagination and filtering. + """ + items(first: Int, after: String, statusFilter: [BulkUpdateItemStatus!]) : BulkUpdateItemConnection! } -type RoleUpdatePayload @join__type(graph: IAM_ADMIN) { - role: Role - userErrors: [GenericUserError!]! +type ProductBulkUpdateJobConnection @join__type(graph: CATALOG_ADMIN) { + edges: [ProductBulkUpdateJobEdge!]! + pageInfo: PageInfo! + totalCount: Int! } -type RoleDeletePayload @join__type(graph: IAM_ADMIN) { - deletedRoleName: String - userErrors: [GenericUserError!]! +type ProductBulkUpdateJobEdge @join__type(graph: CATALOG_ADMIN) { + cursor: String! + node: ProductBulkUpdateJob! } -type AuthorizePayload @join__type(graph: IAM_ADMIN) { +""" +Job progress. All counters computed from items. +""" +type BulkUpdateJobProgress @join__type(graph: CATALOG_ADMIN) { """ - Whether access is allowed. + Total operations. """ - allowed: Boolean! + total: Int! """ - Reason for denial (if denied). + Done (succeeded + failed + cancelled + superseded). """ - deniedReason: String + done: Int! + """ + Successfully applied. + """ + succeeded: Int! + """ + Failed. + """ + failed: Int! + """ + Cancelled. + """ + cancelled: Int! + """ + Superseded by another job. + """ + superseded: Int! + """ + Currently running. + """ + running: Int! + """ + Pending execution. + """ + pending: Int! } """ -User type representing admin users (CMS/backoffice). +Single operation in bulk update job. """ -type User @join__type(graph: IAM_ADMIN, key: "id") @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) { +type BulkUpdateItem @join__type(graph: CATALOG_ADMIN) { """ - The globally unique ID of the user. + Item ID. """ id: ID! """ - User's email address. + Product ID. """ - email: Email! @join__field(graph: IAM_ADMIN) + productId: ID! """ - Whether the email has been verified. + Variant ID (null for product-level operations). """ - emailVerified: Boolean @join__field(graph: IAM_ADMIN) + variantId: ID """ - User's first name. + Operation type. """ - firstName: String @join__field(graph: IAM_ADMIN) + opType: BulkUpdateOpType! """ - User's last name. + Order within product. """ - lastName: String @join__field(graph: IAM_ADMIN) + opIndex: Int! """ - URL to user's avatar image. + Current status. """ - avatar: String @join__field(graph: IAM_ADMIN) + status: BulkUpdateItemStatus! """ - User's locale/language preference. + When started. """ - locale: LocaleCode @join__field(graph: IAM_ADMIN) - """ - Whether the user has admin privileges. - """ - isAdmin: Boolean @join__field(graph: IAM_ADMIN) + startedAt: DateTime """ - Whether the user account is forbidden/banned. + When finished. """ - isForbidden: Boolean @join__field(graph: IAM_ADMIN) + finishedAt: DateTime """ - Whether the user account is deleted. + Execution errors. """ - isDeleted: Boolean @join__field(graph: IAM_ADMIN) + errors: [BulkUpdateUserError!]! """ - The date and time when the user was created. + Cancel reason (only for CANCELLED/SUPERSEDED). """ - createdAt: DateTime @join__field(graph: IAM_ADMIN) + cancelReason: BulkUpdateCancelReason """ - The date and time when the user was last updated. + Job that superseded this item. """ - updatedAt: DateTime @join__field(graph: IAM_ADMIN) + supersededByJobId: ID } -""" -Payload for user profile update. -""" -type UserUpdateProfilePayload @join__type(graph: IAM_ADMIN) { - """ - The updated user. - """ - user: User - """ - List of errors that occurred during the mutation. - """ - userErrors: [GenericUserError!]! +type BulkUpdateItemConnection @join__type(graph: CATALOG_ADMIN) { + edges: [BulkUpdateItemEdge!]! + pageInfo: PageInfo! + totalCount: Int! } -""" -Payload for user email update. -""" -type UserUpdateEmailPayload @join__type(graph: IAM_ADMIN) { - """ - The updated user. - """ - user: User - """ - List of errors that occurred during the mutation. - """ - userErrors: [GenericUserError!]! +type BulkUpdateItemEdge @join__type(graph: CATALOG_ADMIN) { + cursor: String! + node: BulkUpdateItem! } """ -Payload for admin user sign up. +Bulk update error with operation context. """ -type UserSignUpPayload @join__type(graph: IAM_ADMIN) { +type BulkUpdateUserError implements UserError @join__type(graph: CATALOG_ADMIN) @join__implements(graph: CATALOG_ADMIN, interface: "UserError") { """ - The created user. + Error message. """ - user: User + message: String! """ - Authentication tokens. + Input field path. """ - token: AuthTokenPayload + field: [String!] """ - List of errors that occurred during the mutation. + Error code. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for admin user sign in. -""" -type UserSignInPayload @join__type(graph: IAM_ADMIN) { + code: String """ - The authenticated user. + Product ID. """ - user: User + productId: ID """ - Authentication tokens. + Variant ID. """ - token: AuthTokenPayload + variantId: ID """ - List of errors that occurred during the mutation. + Operation that failed. """ - userErrors: [GenericUserError!]! + operation: String } -""" -Payload for admin user sign out. -""" -type UserSignOutPayload @join__type(graph: IAM_ADMIN) { - """ - Whether sign out was successful. - """ - success: Boolean! +type Bundle implements Node & Listing @join__type(graph: CATALOG_ADMIN, key: "id") @join__type(graph: LISTING_ADMIN, key: "id", resolvable: false, extension: true) @join__implements(graph: CATALOG_ADMIN, interface: "Node") @join__implements(graph: LISTING_ADMIN, interface: "Node") @join__implements(graph: LISTING_ADMIN, interface: "Listing") { """ - List of errors that occurred during the mutation. + The Product global ID of the bundle sellable item. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for admin user token refresh. -""" -type UserTokenRefreshPayload @join__type(graph: IAM_ADMIN) { + id: ID! """ - New authentication tokens. + Product discriminator. Always BUNDLE for this type. """ - token: AuthTokenPayload + kind: ProductKind! @join__field(graph: CATALOG_ADMIN) """ - List of errors that occurred during the mutation. + The URL-friendly handle for the bundle. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for user password update. -""" -type UserUpdatePasswordPayload @join__type(graph: IAM_ADMIN) { + handle: String! @join__field(graph: CATALOG_ADMIN) """ - Whether the password was changed successfully. + The date and time when the bundle was published, or null if unpublished. """ - success: Boolean! + publishedAt: DateTime @join__field(graph: CATALOG_ADMIN) """ - List of errors that occurred during the mutation. + Whether the bundle is currently published. """ - userErrors: [GenericUserError!]! -} - -type InventoryQuery @join__type(graph: INVENTORY_ADMIN) { + isPublished: Boolean! @join__field(graph: CATALOG_ADMIN) """ - Get a node by its global ID + The date and time when the bundle was created. """ - node(id: ID!) : Node + createdAt: DateTime! @join__field(graph: CATALOG_ADMIN) """ - Get multiple nodes by their global IDs + The date and time when the bundle was last updated. """ - nodes(ids: [ID!]!) : [Node]! + updatedAt: DateTime! @join__field(graph: CATALOG_ADMIN) """ - Get a product by ID + The date and time when the bundle was deleted (soft delete). """ - product(id: ID!) : Product + deletedAt: DateTime @join__field(graph: CATALOG_ADMIN) """ - Get products with Relay-style pagination + Optimistic locking revision number. Incremented on each update. """ - products(first: Int, after: String, last: Int, before: String) : ProductConnection! + revision: Int! @join__field(graph: CATALOG_ADMIN) """ - Get a variant by ID + The vendor associated with this bundle. """ - variant(id: ID!) : Variant + vendor: Vendor @join__field(graph: CATALOG_ADMIN) """ - Get variants with Relay-style pagination + The variants of this bundle. """ - variants(first: Int, after: String, last: Int, before: String) : VariantConnection! + variants(first: Int, after: String, last: Int, before: String) : VariantConnection! @join__field(graph: CATALOG_ADMIN) """ - Get a warehouse by ID + Media registered on this bundle. """ - warehouse(id: ID!) : Warehouse + media: [ProductMediaItem!]! @join__field(graph: CATALOG_ADMIN) """ - Get all warehouses + The options available for this bundle. """ - warehouses( - first: Int - after: String - last: Int - before: String - """ - Filter conditions - """ - where: WarehouseWhereInput - """ - Sort order - """ - orderBy: [WarehouseOrderByInput!] - ): WarehouseConnection! -} - -type InventoryMutation @join__type(graph: INVENTORY_ADMIN) { - productCreate: ProductCreatePayload! - productUpdate(input: ProductUpdateInput!) : ProductUpdatePayload! - productDelete(input: ProductDeleteInput!) : ProductDeletePayload! - productPublish(input: ProductPublishInput!) : ProductPublishPayload! - productUnpublish(input: ProductUnpublishInput!) : ProductUnpublishPayload! - variantCreate(input: VariantCreateInput!) : VariantCreatePayload! - variantDelete(input: VariantDeleteInput!) : VariantDeletePayload! - variantSetSku(input: VariantSetSkuInput!) : VariantSetSkuPayload! - variantSetDimensions(input: VariantSetDimensionsInput!) : VariantSetDimensionsPayload! - variantSetWeight(input: VariantSetWeightInput!) : VariantSetWeightPayload! - variantSetPricing(input: VariantSetPricingInput!) : VariantSetPricingPayload! - variantSetCost(input: VariantSetCostInput!) : VariantSetCostPayload! - productOptionCreate(input: ProductOptionCreateInput!) : ProductOptionCreatePayload! - productOptionUpdate(input: ProductOptionUpdateInput!) : ProductOptionUpdatePayload! - productOptionDelete(input: ProductOptionDeleteInput!) : ProductOptionDeletePayload! - productFeatureCreate(input: ProductFeatureCreateInput!) : ProductFeatureCreatePayload! - productFeatureUpdate(input: ProductFeatureUpdateInput!) : ProductFeatureUpdatePayload! - productFeatureDelete(input: ProductFeatureDeleteInput!) : ProductFeatureDeletePayload! - warehouseCreate(input: WarehouseCreateInput!) : WarehouseCreatePayload! - warehouseUpdate(input: WarehouseUpdateInput!) : WarehouseUpdatePayload! - warehouseDelete(input: WarehouseDeleteInput!) : WarehouseDeletePayload! - variantSetStock(input: VariantSetStockInput!) : VariantSetStockPayload! - variantSetMedia(input: VariantSetMediaInput!) : VariantSetMediaPayload! -} - -""" -A product feature represents a searchable attribute of a product (e.g., Material, Brand). -""" -type ProductFeature implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { + options: [ProductOption!]! @join__field(graph: CATALOG_ADMIN) """ - The globally unique ID of the feature. + The features of this bundle. """ - id: ID! + features: [ProductFeature!]! @join__field(graph: CATALOG_ADMIN) """ - The URL-friendly identifier for this feature. + The total number of variants for this bundle. """ - slug: String! + variantsCount: Int! @join__field(graph: CATALOG_ADMIN) """ - Display name. + The primary category assigned to this bundle. """ - name: String! + primaryCategory: Category @join__field(graph: CATALOG_ADMIN) """ - The available values for this feature. + Category assignments with relationship metadata. """ - values: [ProductFeatureValue!]! -} - -""" -A value for a product feature. -""" -type ProductFeatureValue implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { + categoryAssignments: [ProductCategoryAssignment!]! @join__field(graph: CATALOG_ADMIN) """ - The globally unique ID of the feature value. + The tags associated with this bundle. """ - id: ID! + tags: [Tag!]! @join__field(graph: CATALOG_ADMIN) """ - The URL-friendly identifier for this value. + Bundle title. """ - slug: String! + title: String! @join__field(graph: CATALOG_ADMIN) """ - Display name. + Bundle description. """ - name: String! -} - -""" -Payload for feature create. -""" -type ProductFeatureCreatePayload @join__type(graph: INVENTORY_ADMIN) { + description: RichText @join__field(graph: CATALOG_ADMIN) """ - The product with updated features. + Short excerpt. """ - product: Product + excerpt: RichText @join__field(graph: CATALOG_ADMIN) """ - The created feature. + SEO and Open Graph metadata. """ - feature: ProductFeature + seo: ProductSeo @join__field(graph: CATALOG_ADMIN) """ - List of errors that occurred during the mutation. + Current bundle price range in the selected currency. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for feature update. -""" -type ProductFeatureUpdatePayload @join__type(graph: INVENTORY_ADMIN) { + priceRange: ProductPriceRange @join__field(graph: CATALOG_ADMIN) """ - The product with updated features. + High-level bundle type. """ - product: Product + type: BundleType @join__field(graph: CATALOG_ADMIN) """ - The updated feature. + Configurator display style. """ - feature: ProductFeature + displayStyle: BundleDisplayStyle! @join__field(graph: CATALOG_ADMIN) """ - List of errors that occurred during the mutation. + All bundle configurations for this bundle. """ - userErrors: [GenericUserError!]! + configurations: [BundleConfiguration!]! @join__field(graph: CATALOG_ADMIN) } """ -Payload for feature delete. +A connection to a list of Bundle items. """ -type ProductFeatureDeletePayload @join__type(graph: INVENTORY_ADMIN) { +type BundleConnection @join__type(graph: CATALOG_ADMIN) { """ - The product with updated features. + A list of edges. """ - product: Product + edges: [BundleEdge!]! """ - The ID of the deleted feature. + Information to aid in pagination. """ - deletedFeatureId: ID + pageInfo: PageInfo! """ - List of errors that occurred during the mutation. + The total number of bundles. """ - userErrors: [GenericUserError!]! + totalCount: Int! } """ -Media attached to a variant with sort order. +An edge in a Bundle connection. """ -type VariantMediaItem @join__type(graph: INVENTORY_ADMIN) { +type BundleEdge @join__type(graph: CATALOG_ADMIN) { """ - The file from the Media service. + The item at the end of the edge. """ - file: File! + node: Bundle! """ - Sort order index (lower = first). + A cursor for use in pagination. """ - sortIndex: Int! + cursor: String! } -""" -Payload for variant set media. -""" -type VariantSetMediaPayload @join__type(graph: INVENTORY_ADMIN) { - """ - The updated variant. - """ - variant: Variant +type BundleConfiguration implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - List of errors that occurred during the mutation. + The globally unique ID of the configuration. """ - userErrors: [GenericUserError!]! -} - -""" -A product option defines a configurable aspect of a product, such as Size or Color. -""" -type ProductOption implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { + id: ID! """ - The globally unique ID of the option. + The bundle root this configuration belongs to. """ - id: ID! + bundle: Bundle! """ - The URL-friendly identifier for this option. + The Product global ID of the bundle this configuration belongs to. """ - slug: String! + bundleId: ID! """ - Display name. + Configuration name. """ name: String! """ - The display type for UI rendering. + Variants that use this configuration. """ - displayType: OptionDisplayType! + variants: [Variant!]! """ - The available values for this option. + Groups in configurator order. """ - values: [ProductOptionValue!]! -} - -""" -A value for a product option, such as "Red" for Color or "Large" for Size. -""" -type ProductOptionValue implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { + groups: [BundleGroup!]! """ - The globally unique ID of the option value. + Reusable pricing templates. """ - id: ID! + pricingTemplates: [BundlePricingTemplate!]! """ - The URL-friendly identifier for this value. + Dependency rules in priority order. """ - slug: String! + dependencyRules: [BundleDependencyRule!]! """ - Display name. + The date and time when the configuration was created. """ - name: String! + createdAt: DateTime! """ - The visual swatch for this value (if applicable). + The date and time when the configuration was last updated. """ - swatch: ProductOptionSwatch + updatedAt: DateTime! } -""" -A visual swatch for representing an option value. -""" -type ProductOptionSwatch implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { +type BundleGroup implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The globally unique ID of the swatch. + The globally unique ID of the group. """ id: ID! """ - The type of swatch. + Sort order within the configuration. """ - swatchType: SwatchType! + sortIndex: Int! """ - The primary color (hex code or color name). + Display title from current locale. """ - colorOne: String + title: String! """ - The secondary color for gradients. + Minimum selected items in this group. Null means no minimum. """ - colorTwo: String + minSelection: Int """ - The file for image-based swatches. + Maximum selected items in this group. Null means no maximum. """ - file: File + maxSelection: Int """ - Additional metadata for the swatch. + Items in group order. """ - metadata: JSON -} - -""" -Represents a selected option for a variant. -""" -type SelectedOption @join__type(graph: INVENTORY_ADMIN) { + items: [BundleItem!]! """ - The option ID. + The date and time when the group was created. """ - optionId: ID! + createdAt: DateTime! """ - The selected value ID. + The date and time when the group was last updated. """ - optionValueId: ID! + updatedAt: DateTime! } -""" -Payload for option create. Returns the product with new variants. -""" -type ProductOptionCreatePayload @join__type(graph: INVENTORY_ADMIN) { +type BundleItem implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The product with updated options and variants. + The globally unique ID of the item. """ - product: Product + id: ID! """ - The created option. + The group this item belongs to. """ - option: ProductOption + group: BundleGroup! """ - List of errors that occurred during the mutation. + The group ID. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for option update. -""" -type ProductOptionUpdatePayload @join__type(graph: INVENTORY_ADMIN) { + groupId: ID! """ - The product with updated options and variants. + Whether the item references a product or a concrete variant. """ - product: Product + itemType: BundleItemType! """ - The updated option. + Sort order within the group. """ - option: ProductOption + sortIndex: Int! """ - List of errors that occurred during the mutation. + Referenced product for PRODUCT items. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for option delete. -""" -type ProductOptionDeletePayload @join__type(graph: INVENTORY_ADMIN) { + refProduct: Product """ - The product with updated options and variants. + Referenced product ID for PRODUCT items. """ - product: Product + refProductId: ID """ - The ID of the deleted option. + Referenced variant for VARIANT items. """ - deletedOptionId: ID + refVariant: Variant """ - List of errors that occurred during the mutation. + Referenced variant ID for VARIANT items. """ - userErrors: [GenericUserError!]! -} - -""" -Physical dimensions of a variant (stored in millimeters). -""" -type VariantDimensions @join__type(graph: INVENTORY_ADMIN) { + refVariantId: ID """ - Width in millimeters. + Featured image override. """ - width: Int! + featuredImage: File """ - Length in millimeters. + Minimum selectable quantity. """ - length: Int! + minQty: Int """ - Height in millimeters. + Maximum selectable quantity. Null means unlimited. """ - height: Int! -} - -""" -Physical weight of a variant (stored in grams). -""" -type VariantWeight @join__type(graph: INVENTORY_ADMIN) { + maxQty: Int """ - Weight in grams. + Default quantity. """ - value: Int! -} - -""" -Represents a price for a variant. -""" -type VariantPrice implements Node @join__type(graph: INVENTORY_ADMIN) @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { + defaultQty: Int """ - The globally unique ID of the price record. + Inline price rule. Null when pricingTemplate is used. """ - id: ID! + priceRule: BundlePriceRule """ - The currency code. + Reusable pricing template. Null when inline priceRule is used. """ - currency: CurrencyCode! + pricingTemplate: BundlePricingTemplate """ - The price amount in minor units (cents, kopecks, etc.). + Allowed option/value selections for PRODUCT items. """ - amountMinor: BigInt! + optionSelections: [BundleItemOptionSelection!]! """ - The compare-at price in minor units (strikethrough price). + Optional display title override from current locale. """ - compareAtMinor: BigInt + title: String """ - When this price became effective. + Whether item is visible in the configurator. """ - effectiveFrom: DateTime! + visible: Boolean! """ - When this price stopped being effective (null if current). + Whether item is selected by default. """ - effectiveTo: DateTime + selected: Boolean! """ - When this price record was created. + The date and time when the item was created. """ - recordedAt: DateTime! + createdAt: DateTime! """ - Whether this is the current active price. + The date and time when the item was last updated. """ - isCurrent: Boolean! + updatedAt: DateTime! } -""" -A connection to a list of VariantPrice items. -""" -type VariantPriceConnection @join__type(graph: INVENTORY_ADMIN) { +type BundleItemOptionSelection implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - A list of edges. + The globally unique ID of the option selection. """ - edges: [VariantPriceEdge!]! + id: ID! """ - Information to aid in pagination. + Referenced product option. """ - pageInfo: PageInfo! + option: ProductOption! """ - The total number of price records. + Referenced product option ID. """ - totalCount: Int! -} - -""" -An edge in a VariantPrice connection. -""" -type VariantPriceEdge @join__type(graph: INVENTORY_ADMIN) { + optionId: ID! """ - The item at the end of the edge. + Parent option for dependent option trees. """ - node: VariantPrice! + parentOption: ProductOption """ - A cursor for use in pagination. + Parent option ID. """ - cursor: String! + parentOptionId: ID + """ + Allowed values for this option. + """ + values: [BundleItemOptionValueSelection!]! + """ + Sort order within item option selections. + """ + sortIndex: Int! } -""" -Represents the cost of a variant. -""" -type VariantCost implements Node @join__type(graph: INVENTORY_ADMIN) @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { +type BundleItemOptionValueSelection implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The globally unique ID of the cost record. + The globally unique ID of the option value selection. """ id: ID! """ - The currency code. + Referenced product option value. Null when the value is unavailable. """ - currency: CurrencyCode! + optionValue: ProductOptionValue """ - The unit cost in minor units. + Referenced product option value ID. """ - unitCostMinor: BigInt! + optionValueId: ID """ - When this cost became effective. + Stable value copy for displaying stale/unavailable values. """ - effectiveFrom: DateTime! + value: String! """ - When this cost stopped being effective (null if current). + Selection status. """ - effectiveTo: DateTime + status: BundleItemOptionValueSelectionStatus! """ - When this cost record was created. + Sort order within option values. """ - recordedAt: DateTime! + sortIndex: Int! +} + +type BundleBasePriceRule implements Node & BundlePriceRule @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") @join__implements(graph: CATALOG_ADMIN, interface: "BundlePriceRule") { """ - Whether this is the current active cost. + The globally unique ID of the price rule. """ - isCurrent: Boolean! + id: ID! + """ + Pricing strategy. + """ + priceType: BundlePriceType! } -""" -A connection to a list of VariantCost items. -""" -type VariantCostConnection @join__type(graph: INVENTORY_ADMIN) { +type BundleFixedPriceRule implements Node & BundlePriceRule @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") @join__implements(graph: CATALOG_ADMIN, interface: "BundlePriceRule") { """ - A list of edges. + The globally unique ID of the price rule. """ - edges: [VariantCostEdge!]! + id: ID! """ - Information to aid in pagination. + Pricing strategy. """ - pageInfo: PageInfo! + priceType: BundlePriceType! """ - The total number of cost records. + Money values for FIXED rules. """ - totalCount: Int! + amounts: [BundlePriceRuleAmount!]! } -""" -An edge in a VariantCost connection. -""" -type VariantCostEdge @join__type(graph: INVENTORY_ADMIN) { +type BundleDiscountPercentPriceRule implements Node & BundlePriceRule @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") @join__implements(graph: CATALOG_ADMIN, interface: "BundlePriceRule") { """ - The item at the end of the edge. + The globally unique ID of the price rule. """ - node: VariantCost! + id: ID! """ - A cursor for use in pagination. + Pricing strategy. """ - cursor: String! + priceType: BundlePriceType! + """ + Percent row for DISCOUNT_PERCENT rules. + """ + percent: BundlePriceRulePercent! } -""" -Payload for variant pricing set. -""" -type VariantSetPricingPayload @join__type(graph: INVENTORY_ADMIN) { +type BundleDiscountFixedPriceRule implements Node & BundlePriceRule @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") @join__implements(graph: CATALOG_ADMIN, interface: "BundlePriceRule") { """ - The updated variant. + The globally unique ID of the price rule. """ - variant: Variant + id: ID! """ - List of errors that occurred during the mutation. + Pricing strategy. """ - userErrors: [GenericUserError!]! + priceType: BundlePriceType! + """ + Money values for DISCOUNT_FIXED rules. + """ + amounts: [BundlePriceRuleAmount!]! } -""" -Payload for variant cost set. -""" -type VariantSetCostPayload @join__type(graph: INVENTORY_ADMIN) { +type BundleFreePriceRule implements Node & BundlePriceRule @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") @join__implements(graph: CATALOG_ADMIN, interface: "BundlePriceRule") { """ - The updated variant. + The globally unique ID of the price rule. """ - variant: Variant + id: ID! """ - List of errors that occurred during the mutation. + Pricing strategy. """ - userErrors: [GenericUserError!]! + priceType: BundlePriceType! } -""" -Product description in multiple formats. -""" -type Description @join__type(graph: INVENTORY_ADMIN) { +type BundlePriceRuleAmount @join__type(graph: CATALOG_ADMIN) { """ - Plain text description. + The currency code. """ - text: String! + currency: CurrencyCode! """ - HTML description. + Amount in minor units. """ - html: String! + amountMinor: BigInt! +} + +type BundlePriceRulePercent @join__type(graph: CATALOG_ADMIN) { """ - EditorJS JSON description. + Percent value, 0..100. """ - json: JSON! + value: Int! } -""" -A product represents an item that can be sold. -""" -type Product implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { +type BundlePricingTemplate implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The globally unique ID of the product. + The globally unique ID of the pricing template. """ id: ID! """ - The URL-friendly handle for the product. + Template name. """ - handle: String + name: String! """ - The date and time when the product was published, or null if unpublished. + Reusable price rule. """ - publishedAt: DateTime + priceRule: BundlePriceRule! """ - Whether the product is currently published. + Sort order within configuration. """ - isPublished: Boolean! + sortIndex: Int! +} + +type BundleDependencyRule implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The date and time when the product was created. + The globally unique ID of the dependency rule. """ - createdAt: DateTime! + id: ID! """ - The date and time when the product was last updated. + Rule name. """ - updatedAt: DateTime! + name: String! """ - The date and time when the product was deleted (soft delete). + Whether the rule is enabled. """ - deletedAt: DateTime + enabled: Boolean! """ - The variants of this product. + Rule priority. Lower values are evaluated first. """ - variants(first: Int, after: String, last: Int, before: String) : VariantConnection! + priority: Int! """ - The options available for this product. + How condition groups are combined. """ - options: [ProductOption!]! + logicOperator: BundleLogicOperator! """ - The features of this product. + Condition groups. """ - features: [ProductFeature!]! + conditionGroups: [BundleConditionGroup!]! """ - The total number of variants for this product. + Actions applied when conditions match. """ - variantsCount: Int! + actions: [BundleDependencyAction!]! """ - Product title. + The date and time when the rule was created. """ - title: String! + createdAt: DateTime! """ - Product description. + The date and time when the rule was last updated. + """ + updatedAt: DateTime! +} + +type BundleConditionGroup implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - description: Description + The globally unique ID of the condition group. """ - Short excerpt. + id: ID! """ - excerpt: String + How conditions are combined. """ - SEO title. + logicOperator: BundleLogicOperator! """ - seoTitle: String + Conditions in this group. """ - SEO description. + conditions: [BundleCondition!]! """ - seoDescription: String + Sort order within the rule. + """ + sortIndex: Int! } -""" -A connection to a list of Product items. -""" -type ProductConnection @join__type(graph: INVENTORY_ADMIN) { +type BundleCondition implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - A list of edges. + The globally unique ID of the condition. """ - edges: [ProductEdge!]! + id: ID! """ - Information to aid in pagination. + Condition category. """ - pageInfo: PageInfo! + category: BundleConditionCategory! """ - The total number of products. + Condition subject. """ - totalCount: Int! -} - -""" -An edge in a Product connection. -""" -type ProductEdge @join__type(graph: INVENTORY_ADMIN) { + subject: BundleConditionSubject! """ - The item at the end of the edge. + Condition operator. """ - node: Product! + operator: BundleConditionOperator! """ - A cursor for use in pagination. + Target type. """ - cursor: String! -} - -""" -Payload for product update. -""" -type ProductUpdatePayload @join__type(graph: INVENTORY_ADMIN) { + targetType: BundleDependencyTargetType! """ - The updated product. + Target ID. Points to an item, group, or the parent bundle product. """ - product: Product + targetId: ID! """ - List of errors that occurred during the mutation. + Numeric value for numeric conditions. """ - userErrors: [GenericUserError!]! + value: Int + """ + Sort order within the condition group. + """ + sortIndex: Int! } -""" -Payload for product creation. -""" -type ProductCreatePayload @join__type(graph: INVENTORY_ADMIN) { +type BundleDependencyAction implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The created product. + The globally unique ID of the action. """ - product: Product + id: ID! """ - List of errors that occurred during the mutation. + Action type. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for product deletion. -""" -type ProductDeletePayload @join__type(graph: INVENTORY_ADMIN) { + actionType: BundleDependencyActionType! """ - The ID of the deleted product. + Target type. """ - deletedProductId: ID + targetType: BundleDependencyTargetType! """ - List of errors that occurred during the mutation. + Target ID. Null is allowed when targetType is BUNDLE. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for product publish. -""" -type ProductPublishPayload @join__type(graph: INVENTORY_ADMIN) { + targetId: ID """ - The published product. + Required value for SET_REQUIRED. """ - product: Product + requiredValue: Boolean """ - List of errors that occurred during the mutation. + Price rule for ADJUST_PRICE. """ - userErrors: [GenericUserError!]! -} - -""" -Payload for product unpublish. -""" -type ProductUnpublishPayload @join__type(graph: INVENTORY_ADMIN) { + priceRule: BundlePriceRule """ - The unpublished product. + Whether this action can stack with other matching actions. """ - product: Product + stackable: Boolean! """ - List of errors that occurred during the mutation. + Sort order within the rule. """ + sortIndex: Int! +} + +type BundleCreatePayload @join__type(graph: CATALOG_ADMIN) { + bundle: Bundle + userErrors: [GenericUserError!]! +} + +type BundleUpdatePayload @join__type(graph: CATALOG_ADMIN) { + bundle: Bundle + userErrors: [GenericUserError!]! +} + +type BundleConfigurationPayload @join__type(graph: CATALOG_ADMIN) { + configuration: BundleConfiguration + userErrors: [GenericUserError!]! +} + +type BundleConfigurationDeletePayload @join__type(graph: CATALOG_ADMIN) { + deletedConfigurationId: ID + bundle: Bundle + userErrors: [GenericUserError!]! +} + +type BundleGroupsSyncPayload @join__type(graph: CATALOG_ADMIN) { + configuration: BundleConfiguration + groups: [BundleGroup!]! + userErrors: [GenericUserError!]! +} + +type BundlePricingTemplatesSyncPayload @join__type(graph: CATALOG_ADMIN) { + configuration: BundleConfiguration + pricingTemplates: [BundlePricingTemplate!]! + userErrors: [GenericUserError!]! +} + +type BundleDependencyRulesSyncPayload @join__type(graph: CATALOG_ADMIN) { + configuration: BundleConfiguration + dependencyRules: [BundleDependencyRule!]! userErrors: [GenericUserError!]! } """ -A warehouse represents a physical location where inventory is stored. +A category represents a hierarchical grouping of products. """ -type Warehouse implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { +type Category implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The globally unique ID of the warehouse. + The globally unique ID of the category. """ id: ID! """ - The unique code identifying this warehouse. + The URL-friendly handle for the category. """ - code: String! + handle: String! """ - The display name of the warehouse. + The date and time when the category was published, or null if unpublished. """ - name: String! + publishedAt: DateTime """ - Whether this is the default warehouse for the project. + Whether the category is currently published. """ - isDefault: Boolean! + isPublished: Boolean! """ - The date and time when the warehouse was created. + The date and time when the category was created. """ createdAt: DateTime! """ - The date and time when the warehouse was last updated. + The date and time when the category was last updated. """ updatedAt: DateTime! """ - Stock levels for all variants in this warehouse. + The date and time when the category was deleted (soft delete). """ - stock( - first: Int - after: String - last: Int - before: String - """ - Filter conditions - """ - where: WarehouseStockWhereInput - """ - Sort order - """ - orderBy: [WarehouseStockOrderByInput!] - ): WarehouseStockConnection! + deletedAt: DateTime """ - Total number of variants stocked in this warehouse. + Optimistic locking revision number. Incremented on each update. """ - variantsCount: Int! -} - -""" -A connection to a list of Warehouse items. -""" -type WarehouseConnection @join__type(graph: INVENTORY_ADMIN) { + revision: Int! """ - A list of edges. + The depth of this category in the hierarchy (0 for root). """ - edges: [WarehouseEdge!]! + depth: Int! """ - Information to aid in pagination. + The materialized path for this category. """ - pageInfo: PageInfo! + path: String! """ - The total number of warehouses. + The display name of the category. """ - totalCount: Int! -} - -""" -An edge in a Warehouse connection. -""" -type WarehouseEdge @join__type(graph: INVENTORY_ADMIN) { + name: String! """ - The item at the end of the edge. + The category description. """ - node: Warehouse! + description: RichText """ - A cursor for use in pagination. + Short category excerpt. """ - cursor: String! -} - -""" -Represents stock level for a variant in a specific warehouse. -""" -type WarehouseStock implements Node @join__type(graph: INVENTORY_ADMIN) @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { + excerpt: RichText """ - The globally unique ID of the stock record. + Default product sort for this category PLP. """ - id: ID! + defaultSort: ProductSortBy! """ - The warehouse where this stock is located. + Default sort direction for this category PLP. """ - warehouse: Warehouse! + defaultSortDirection: SortDirection! """ - The variant this stock record is for. + SEO metadata. """ - variant: Variant! + seo: Seo """ - The quantity currently on hand. + The parent category, if any. """ - quantityOnHand: Int! + parent: Category """ - The date and time when the stock was created. + Direct child categories. """ - createdAt: DateTime! + children: [Category!]! """ - The date and time when the stock was last updated. + All ancestor categories from root to parent. """ - updatedAt: DateTime! + ancestors: [Category!]! + """ + Media files associated with this category. + """ + media: [CategoryMediaItem!]! + """ + The total number of products in this category. + """ + productsCount: Int! } """ -A connection to a list of WarehouseStock items. +A media item for a category. +""" +type CategoryMediaItem @join__type(graph: CATALOG_ADMIN) { + """ + The file reference. + """ + file: File! + """ + The sort index for ordering. + """ + sortIndex: Int! +} + +""" +A connection to a list of Category items. """ -type WarehouseStockConnection @join__type(graph: INVENTORY_ADMIN) { +type CategoryConnection @join__type(graph: CATALOG_ADMIN) { """ A list of edges. """ - edges: [WarehouseStockEdge!]! + edges: [CategoryEdge!]! """ Information to aid in pagination. """ pageInfo: PageInfo! """ - The total number of stock records. + The total number of categories. """ totalCount: Int! } """ -An edge in a WarehouseStock connection. +An edge in a Category connection. """ -type WarehouseStockEdge @join__type(graph: INVENTORY_ADMIN) { +type CategoryEdge @join__type(graph: CATALOG_ADMIN) { """ The item at the end of the edge. """ - node: WarehouseStock! + node: Category! """ A cursor for use in pagination. """ @@ -1698,13 +1633,13 @@ type WarehouseStockEdge @join__type(graph: INVENTORY_ADMIN) { } """ -Payload for warehouse creation. +Payload for category creation. """ -type WarehouseCreatePayload @join__type(graph: INVENTORY_ADMIN) { +type CategoryCreatePayload @join__type(graph: CATALOG_ADMIN) { """ - The created warehouse. + The created category. """ - warehouse: Warehouse + category: Category """ List of errors that occurred during the mutation. """ @@ -1712,13 +1647,17 @@ type WarehouseCreatePayload @join__type(graph: INVENTORY_ADMIN) { } """ -Payload for warehouse update. +Payload for category update. """ -type WarehouseUpdatePayload @join__type(graph: INVENTORY_ADMIN) { +type CategoryUpdatePayload @join__type(graph: CATALOG_ADMIN) { """ - The updated warehouse. + The updated category. """ - warehouse: Warehouse + category: Category + """ + Results of requested category update operations. + """ + operationResults: [OperationResult!]! """ List of errors that occurred during the mutation. """ @@ -1726,13 +1665,13 @@ type WarehouseUpdatePayload @join__type(graph: INVENTORY_ADMIN) { } """ -Payload for warehouse deletion. +Payload for category move. """ -type WarehouseDeletePayload @join__type(graph: INVENTORY_ADMIN) { +type CategoryMovePayload @join__type(graph: CATALOG_ADMIN) { """ - The ID of the deleted warehouse. + The moved category. """ - deletedWarehouseId: ID + category: Category """ List of errors that occurred during the mutation. """ @@ -1740,213 +1679,407 @@ type WarehouseDeletePayload @join__type(graph: INVENTORY_ADMIN) { } """ -Payload for variant stock set. +Payload for category deletion. """ -type VariantSetStockPayload @join__type(graph: INVENTORY_ADMIN) { - """ - The updated stock record. +type CategoryDeletePayload @join__type(graph: CATALOG_ADMIN) { """ - stock: WarehouseStock + The ID of the deleted category. """ - The updated variant. - """ - variant: Variant + deletedCategoryId: ID """ List of errors that occurred during the mutation. """ userErrors: [GenericUserError!]! } +type CategoryRebalancePayload @join__type(graph: CATALOG_ADMIN) { + category: Category + userErrors: [GenericUserError!]! +} + +type Collection implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { + id: ID! + handle: String + type: CollectionType! + name: String! + description: RichText + excerpt: RichText + media: [CollectionMediaItem!]! + seo: Seo + defaultSort: ProductSortBy! + defaultSortDirection: SortDirection! + activeFrom: DateTime + activeTo: DateTime + isActive: Boolean! + publishedAt: DateTime + isPublished: Boolean! + createdAt: DateTime! + updatedAt: DateTime! + rules: [CollectionRule!]! + products(first: Int, after: String, last: Int, before: String, sort: ProductSortInput) : CollectionProductConnection! + productsCount: Int! +} + +type CollectionRule @join__type(graph: CATALOG_ADMIN) { + id: ID! + field: String! + operator: String! + value: JSON! + sortIndex: Int! +} + +type CollectionMediaItem @join__type(graph: CATALOG_ADMIN) { + file: File! + sortIndex: Int! +} + +type CollectionConnection @join__type(graph: CATALOG_ADMIN) { + edges: [CollectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type CollectionEdge @join__type(graph: CATALOG_ADMIN) { + node: Collection! + cursor: String! +} + +type CollectionProductConnection @join__type(graph: CATALOG_ADMIN) { + edges: [CollectionProductEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type CollectionProductEdge @join__type(graph: CATALOG_ADMIN) { + node: Product! + cursor: String! +} + +type CollectionCreatePayload @join__type(graph: CATALOG_ADMIN) { + collection: Collection + userErrors: [GenericUserError!]! +} + +type CollectionUpdatePayload @join__type(graph: CATALOG_ADMIN) { + collection: Collection + userErrors: [GenericUserError!]! +} + +type CollectionDeletePayload @join__type(graph: CATALOG_ADMIN) { + deletedCollectionId: ID + userErrors: [GenericUserError!]! +} + +type CollectionAddProductsPayload @join__type(graph: CATALOG_ADMIN) { + collection: Collection + userErrors: [GenericUserError!]! +} + +type CollectionRemoveProductsPayload @join__type(graph: CATALOG_ADMIN) { + collection: Collection + userErrors: [GenericUserError!]! +} + +type CollectionMoveProductPayload @join__type(graph: CATALOG_ADMIN) { + collection: Collection + userErrors: [GenericUserError!]! +} + +type CollectionUpdateRulesPayload @join__type(graph: CATALOG_ADMIN) { + collection: Collection + userErrors: [GenericUserError!]! +} + """ -A variant represents a specific version of a product, such as a size or color. +A product feature represents either a group or an attribute. """ -type Variant implements Node @join__type(graph: INVENTORY_ADMIN, key: "id") @join__implements(graph: INVENTORY_ADMIN, interface: "Node") { +type ProductFeature implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The globally unique ID of the variant. + The globally unique ID of the feature. """ id: ID! """ - The product this variant belongs to. - """ - product: Product! - """ - Whether this is the default variant for the product. - """ - isDefault: Boolean! - """ - The URL-friendly handle for the variant (generated from options). + The URL-friendly slug for this feature. """ - handle: String! + slug: String! """ - The SKU (Stock Keeping Unit) of the variant. + Tree position as array: [0] for root, [0, 1] for child of first group. """ - sku: String + index: [Int!]! """ - The external system identifier for integration purposes. + Whether this feature is a group (container) or an attribute (leaf). """ - externalSystem: String + isGroup: Boolean! """ - The external ID in the external system. + Display name (from translations). """ - externalId: String + name: String! """ - The date and time when the variant was created. + Parent group, if this feature belongs to a group. """ - createdAt: DateTime! + parent: ProductFeature """ - The date and time when the variant was last updated. + Child features. Returns empty array for attributes (isGroup = false). """ - updatedAt: DateTime! + children: [ProductFeature!]! """ - The date and time when the variant was deleted (soft delete). + Values. Returns empty array for groups (isGroup = true). """ - deletedAt: DateTime + values: [ProductFeatureValue!]! +} + +""" +A value for a product feature. +""" +type ProductFeatureValue implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - Current price for this variant. + The globally unique ID of the feature value. """ - price: VariantPrice + id: ID! """ - Price history for this variant. + The URL-friendly slug for this feature value. """ - priceHistory(first: Int, after: String, last: Int, before: String) : VariantPriceConnection! + slug: String! """ - Current cost for this variant. + Position within the feature's values (0, 1, 2, ...). """ - cost: VariantCost + index: Int! """ - Cost history for this variant. + Display name (from translations). """ - costHistory(first: Int, after: String, last: Int, before: String) : VariantCostConnection! + name: String! +} + +type ProductFeaturesSyncPayload @join__type(graph: CATALOG_ADMIN) { """ - The selected option values for this variant. + The updated product. """ - selectedOptions: [SelectedOption!]! + product: Product """ - Physical dimensions of this variant. + List of all synced features with their final IDs. """ - dimensions: VariantDimensions + features: [ProductFeature!]! """ - Physical weight of this variant. + Any validation errors. """ - weight: VariantWeight - """ - Stock levels for this variant across warehouses. - """ - stock: [WarehouseStock!]! + userErrors: [GenericUserError!]! +} + +""" +Payload for feature create. +""" +type ProductFeatureCreatePayload @join__type(graph: CATALOG_ADMIN) { """ - Whether the variant is in stock (has quantity > 0 in any warehouse). + The product with updated features. """ - inStock: Boolean! + product: Product """ - Variant title. + The created feature. """ - title: String + feature: ProductFeature """ - Media attached to this variant (images, videos). + List of errors that occurred during the mutation. """ - media: [VariantMediaItem!]! + userErrors: [GenericUserError!]! } """ -A connection to a list of Variant items. +Payload for feature update. """ -type VariantConnection @join__type(graph: INVENTORY_ADMIN) { +type ProductFeatureUpdatePayload @join__type(graph: CATALOG_ADMIN) { """ - A list of edges. + The product with updated features. """ - edges: [VariantEdge!]! + product: Product """ - Information to aid in pagination. + The updated feature. """ - pageInfo: PageInfo! + feature: ProductFeature """ - The total number of variants. + List of errors that occurred during the mutation. """ - totalCount: Int! + userErrors: [GenericUserError!]! } """ -An edge in a Variant connection. +Payload for feature delete. """ -type VariantEdge @join__type(graph: INVENTORY_ADMIN) { +type ProductFeatureDeletePayload @join__type(graph: CATALOG_ADMIN) { """ - The item at the end of the edge. + The product with updated features. """ - node: Variant! + product: Product """ - A cursor for use in pagination. + The ID of the deleted feature. """ - cursor: String! + deletedFeatureId: ID + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! } """ -Payload for variant creation. +InventoryItem represents the inventory-specific data for a variant. +Each catalog variant can have a corresponding InventoryItem. """ -type VariantCreatePayload @join__type(graph: INVENTORY_ADMIN) { +type InventoryItem implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The created variant. + Global ID (Relay) """ - variant: Variant + id: ID! """ - List of errors that occurred during the mutation. + Reference to Catalog.Variant """ - userErrors: [GenericUserError!]! + variantId: ID! + """ + Catalog variant entity + """ + variant: Variant! + """ + SKU code + """ + sku: String + """ + Whether to track inventory for this item + """ + trackInventory: Boolean! + """ + Whether to continue selling when out of stock + """ + continueSellingWhenOutOfStock: Boolean! + """ + Stock levels across warehouses + """ + stock: [WarehouseStock!]! + """ + Total quantity available across all warehouses + """ + totalAvailable: Int! + """ + Current unit cost + """ + unitCost: InventoryItemCost + """ + When this item was created + """ + createdAt: DateTime! + """ + When this item was last updated + """ + updatedAt: DateTime! } -""" -Payload for variant SKU set. -""" -type VariantSetSkuPayload @join__type(graph: INVENTORY_ADMIN) { +type InventoryItemCost @join__type(graph: CATALOG_ADMIN) { """ - The updated variant. + Currency code """ - variant: Variant + currency: String! """ - List of errors that occurred during the mutation. + Cost in minor units (cents) """ - userErrors: [GenericUserError!]! + amountMinor: BigInt! + """ + Effective from date + """ + effectiveFrom: DateTime! } -""" -Payload for variant dimensions set. -""" -type VariantSetDimensionsPayload @join__type(graph: INVENTORY_ADMIN) { +type InventoryItemConnection @join__type(graph: CATALOG_ADMIN) { + edges: [InventoryItemEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type InventoryItemEdge @join__type(graph: CATALOG_ADMIN) { + node: InventoryItem! + cursor: String! +} + +type InventoryItemUpdatePayload @join__type(graph: CATALOG_ADMIN) { """ - The updated variant. + Updated inventory item """ - variant: Variant + inventoryItem: InventoryItem """ - List of errors that occurred during the mutation. + List of errors """ userErrors: [GenericUserError!]! } +type SkuStatusMetric @join__type(graph: CATALOG_ADMIN) { + count: Int! + averageDays: Float +} + +type InventoryQuantities @join__type(graph: CATALOG_ADMIN) { + availableForSale: Int! + onHand: Int! + reserved: Int! + unavailable: Int! +} + +type InventorySkuStatus @join__type(graph: CATALOG_ADMIN) { + total: Int! + lowStock: SkuStatusMetric! + outOfStock: SkuStatusMetric! + backorder: SkuStatusMetric! +} + +type InventoryBackorder @join__type(graph: CATALOG_ADMIN) { + quantity: Int! + etaAvgDays: Float +} + +type InventoryAlertThreshold @join__type(graph: CATALOG_ADMIN) { + method: ThresholdMethod! + minimumStock: Int! +} + +type ProductInventoryWidget @join__type(graph: CATALOG_ADMIN) { + quantities: InventoryQuantities! + availableChange7d: Int! + skuStatus: InventorySkuStatus! + backorder: InventoryBackorder! + alertThreshold: InventoryAlertThreshold! +} + """ -Payload for variant weight set. +Media registered on a product with sort order. """ -type VariantSetWeightPayload @join__type(graph: INVENTORY_ADMIN) { +type ProductMediaItem @join__type(graph: CATALOG_ADMIN) { """ - The updated variant. + The file from the Media service. """ - variant: Variant + file: File! """ - List of errors that occurred during the mutation. + Sort order index (lower = first). """ - userErrors: [GenericUserError!]! + sortIndex: Int! } """ -Payload for variant deletion. +Media attached to a variant with sort order. """ -type VariantDeletePayload @join__type(graph: INVENTORY_ADMIN) { +type VariantMediaItem @join__type(graph: CATALOG_ADMIN) { """ - The ID of the deleted variant. + The file from the Media service. """ - deletedVariantId: ID + file: File! """ - The product the variant belonged to. + Sort order index (lower = first). """ - product: Product + sortIndex: Int! +} + +""" +Payload for variant update media. +""" +type VariantUpdateMediaPayload @join__type(graph: CATALOG_ADMIN) { + """ + The updated variant. + """ + variant: Variant """ List of errors that occurred during the mutation. """ @@ -1954,3837 +2087,9354 @@ type VariantDeletePayload @join__type(graph: INVENTORY_ADMIN) { } """ -A file represents a stored media asset. +A product option defines a configurable aspect of a product, such as Size or Color. """ -type File implements Node @join__type(graph: INVENTORY_ADMIN, key: "id", extension: true) @join__type(graph: MEDIA_ADMIN, key: "id") @join__implements(graph: MEDIA_ADMIN, interface: "Node") { +type ProductOption implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The globally unique ID of the file. + The globally unique ID of the option. """ id: ID! """ - Provider type (s3, youtube, vimeo, url, local). - """ - provider: FileProvider! @join__field(graph: MEDIA_ADMIN) - """ - Public URL to access file. + The URL-friendly identifier for this option. """ - url: String! @join__field(graph: MEDIA_ADMIN) + slug: String! """ - MIME type. + Display name. """ - mimeType: String @join__field(graph: MEDIA_ADMIN) + name: String! """ - File extension. + The display type for UI rendering. """ - ext: String @join__field(graph: MEDIA_ADMIN) + displayType: OptionDisplayType! """ - Size in bytes (0 for external providers). + Sort order within the product options list. """ - sizeBytes: BigInt! @join__field(graph: MEDIA_ADMIN) + sortIndex: Int! """ - Original filename from upload. + The available values for this option. """ - originalName: String @join__field(graph: MEDIA_ADMIN) + values: [ProductOptionValue!]! +} + +""" +A value for a product option, such as "Red" for Color or "Large" for Size. +""" +type ProductOptionValue implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - Image/video dimensions (null if not applicable). + The globally unique ID of the option value. """ - dimensions: MediaDimensions @join__field(graph: MEDIA_ADMIN) + id: ID! """ - Duration in milliseconds (for video/audio). + The URL-friendly identifier for this value. """ - durationMs: Int @join__field(graph: MEDIA_ADMIN) + slug: String! """ - Alt text for accessibility. + Display name. """ - altText: String @join__field(graph: MEDIA_ADMIN) + name: String! """ - Source URL (for files uploaded from URL). + Sort order within the option values list. """ - sourceUrl: String @join__field(graph: MEDIA_ADMIN) + sortIndex: Int! """ - Whether the file has been processed. + The visual swatch for this value (if applicable). """ - isProcessed: Boolean! @join__field(graph: MEDIA_ADMIN) + swatch: ProductOptionSwatch +} + +""" +A visual swatch for representing an option value. +""" +type ProductOptionSwatch implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - Additional metadata. + The globally unique ID of the swatch. """ - meta: JSON @join__field(graph: MEDIA_ADMIN) + id: ID! """ - The date and time when the file was created. + The type of swatch. """ - createdAt: DateTime! @join__field(graph: MEDIA_ADMIN) + swatchType: SwatchType! """ - The date and time when the file was last updated. + The primary color (hex code or color name). """ - updatedAt: DateTime! @join__field(graph: MEDIA_ADMIN) + colorOne: String """ - The date and time when the file was deleted (soft delete). + The secondary color for gradients. """ - deletedAt: DateTime @join__field(graph: MEDIA_ADMIN) + colorTwo: String """ - S3-specific data (only for S3 provider). + The file for image-based swatches. """ - s3Data: S3ObjectData @join__field(graph: MEDIA_ADMIN) + file: File """ - External media data (for YouTube, Vimeo, etc). + Additional metadata for the swatch. """ - externalData: ExternalMediaData @join__field(graph: MEDIA_ADMIN) + metadata: JSON } -type MediaQuery @join__type(graph: MEDIA_ADMIN) { - """ - Get a node by its global ID - """ - node(id: ID!) : Node +""" +Represents a selected option for a variant. +""" +type SelectedOption @join__type(graph: CATALOG_ADMIN) { """ - Get multiple nodes by their global IDs + The option ID. """ - nodes(ids: [ID!]!) : [Node]! + optionId: ID! """ - Get a file by ID + The selected value ID. """ - file(id: ID!) : File -} - -type MediaMutation @join__type(graph: MEDIA_ADMIN) { - bucketCreate(input: BucketCreateInput!) : BucketCreatePayload! - fileUpload(input: FileUploadMultipartInput!) : FileUploadPayload! - fileUploadFromUrl(input: FileUploadFromUrlInput!) : FileUploadPayload! - fileCreateExternal(input: FileCreateExternalInput!) : FileCreateExternalPayload! - fileUpdate(input: FileUpdateInput!) : FileUpdatePayload! - fileDelete(input: FileDeleteInput!) : FileDeletePayload! + optionValueId: ID! } """ -A bucket represents an S3 storage bucket for a project. +Payload for option create. Returns the product with new variants. """ -type Bucket @join__type(graph: MEDIA_ADMIN) { +type ProductOptionCreatePayload @join__type(graph: CATALOG_ADMIN) { """ - The globally unique ID of the bucket. + The product with updated options and variants. """ - id: ID! + product: Product """ - S3 bucket name. + The created option. """ - bucketName: String! + option: ProductOption """ - AWS region. + List of errors that occurred during the mutation. """ - region: String! + userErrors: [GenericUserError!]! +} + +""" +Payload for option update. +""" +type ProductOptionUpdatePayload @join__type(graph: CATALOG_ADMIN) { """ - Bucket status (active, archived, etc). + The product with updated options and variants. """ - status: String! + product: Product """ - Priority for bucket selection. + The updated option. """ - priority: Int! + option: ProductOption """ - Custom endpoint URL (for S3-compatible storage). + List of errors that occurred during the mutation. """ - endpointUrl: String + userErrors: [GenericUserError!]! +} + +""" +Payload for option delete. +""" +type ProductOptionDeletePayload @join__type(graph: CATALOG_ADMIN) { """ - The date and time when the bucket was created. + The product with updated options and variants. """ - createdAt: DateTime! + product: Product """ - The date and time when the bucket was last updated. + The ID of the deleted option. """ - updatedAt: DateTime! + deletedOptionId: ID + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! } """ -Payload for bucket creation. +Payload for options sync mutation. """ -type BucketCreatePayload @join__type(graph: MEDIA_ADMIN) { +type ProductOptionsSyncPayload @join__type(graph: CATALOG_ADMIN) { """ - The created bucket. + The product with updated options. """ - bucket: Bucket + product: Product """ - List of errors that occurred during the mutation. + All synced options with final IDs. + """ + options: [ProductOption!]! + """ + List of errors that occurred. """ userErrors: [GenericUserError!]! } """ -Image/video dimensions. +Physical dimensions of a variant (stored in millimeters). """ -type MediaDimensions @join__type(graph: MEDIA_ADMIN) { +type VariantDimensions @join__type(graph: CATALOG_ADMIN) { """ - Width in pixels. + Width in millimeters. """ width: Int! """ - Height in pixels. + Length in millimeters. + """ + length: Int! + """ + Height in millimeters. """ height: Int! } """ -S3-specific file data. +Physical weight of a variant (stored in grams). """ -type S3ObjectData @join__type(graph: MEDIA_ADMIN) { +type VariantWeight @join__type(graph: CATALOG_ADMIN) { """ - The bucket ID where this file is stored. + Weight in grams. """ - bucketId: ID! + value: Int! +} + +""" +Represents a price for a variant. +""" +type VariantPrice implements Node @join__type(graph: CATALOG_ADMIN) @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - S3 object key (path within bucket). + The globally unique ID of the price record. """ - objectKey: String! + id: ID! """ - Content hash (SHA-256) for deduplication. + The currency code. """ - contentHash: String + currency: CurrencyCode! """ - ETag from S3. + The price amount in minor units (cents, kopecks, etc.). """ - etag: String + amountMinor: BigInt! """ - Storage class (STANDARD, GLACIER, etc). + The compare-at price in minor units (strikethrough price). """ - storageClass: String! + compareAtMinor: BigInt + """ + When this price became effective. + """ + effectiveFrom: DateTime! + """ + When this price stopped being effective (null if current). + """ + effectiveTo: DateTime + """ + When this price record was created. + """ + recordedAt: DateTime! + """ + Whether this is the current active price. + """ + isCurrent: Boolean! } """ -External media data (YouTube, Vimeo, etc). +A connection to a list of VariantPrice items. """ -type ExternalMediaData @join__type(graph: MEDIA_ADMIN) { +type VariantPriceConnection @join__type(graph: CATALOG_ADMIN) { """ - External ID (YouTube video ID, Vimeo ID, etc). + A list of edges. """ - externalId: String! + edges: [VariantPriceEdge!]! """ - Provider-specific metadata. + Information to aid in pagination. """ - providerMeta: JSON + pageInfo: PageInfo! + """ + The total number of price records. + """ + totalCount: Int! } """ -Payload for file upload. +An edge in a VariantPrice connection. """ -type FileUploadPayload @join__type(graph: MEDIA_ADMIN) { +type VariantPriceEdge @join__type(graph: CATALOG_ADMIN) { """ - The uploaded file. + The item at the end of the edge. """ - file: File + node: VariantPrice! """ - List of errors that occurred during the mutation. + A cursor for use in pagination. """ - userErrors: [GenericUserError!]! + cursor: String! } """ -Payload for external file creation. +Represents the cost of a variant. """ -type FileCreateExternalPayload @join__type(graph: MEDIA_ADMIN) { +type VariantCost implements Node @join__type(graph: CATALOG_ADMIN) @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - The created file. + The globally unique ID of the cost record. """ - file: File + id: ID! """ - List of errors that occurred during the mutation. + The currency code. """ - userErrors: [GenericUserError!]! + currency: CurrencyCode! + """ + The unit cost in minor units. + """ + unitCostMinor: BigInt! + """ + When this cost became effective. + """ + effectiveFrom: DateTime! + """ + When this cost stopped being effective (null if current). + """ + effectiveTo: DateTime + """ + When this cost record was created. + """ + recordedAt: DateTime! + """ + Whether this is the current active cost. + """ + isCurrent: Boolean! } """ -Payload for file update. +A connection to a list of VariantCost items. """ -type FileUpdatePayload @join__type(graph: MEDIA_ADMIN) { +type VariantCostConnection @join__type(graph: CATALOG_ADMIN) { """ - The updated file. + A list of edges. """ - file: File + edges: [VariantCostEdge!]! """ - List of errors that occurred during the mutation. + Information to aid in pagination. """ - userErrors: [GenericUserError!]! + pageInfo: PageInfo! + """ + The total number of cost records. + """ + totalCount: Int! } """ -Payload for file deletion. +An edge in a VariantCost connection. """ -type FileDeletePayload @join__type(graph: MEDIA_ADMIN) { +type VariantCostEdge @join__type(graph: CATALOG_ADMIN) { """ - The ID of the deleted file. + The item at the end of the edge. """ - deletedFileId: ID + node: VariantCost! """ - List of errors that occurred during the mutation. + A cursor for use in pagination. """ - userErrors: [GenericUserError!]! + cursor: String! } -type OrderCustomerIdentity @join__type(graph: ORDERS_ADMIN) { - customer: Customer - email: Email - phone: String - countryCode: CountryCode - data: JSON +""" +Statistics for variant price history over a period. +""" +type VariantPriceHistoryStatistics @join__type(graph: CATALOG_ADMIN) { + """ + Minimum price over the period (minor units). + """ + minPriceMinor: BigInt! + """ + Maximum price over the period (minor units). + """ + maxPriceMinor: BigInt! + """ + Average price over the period (minor units). + """ + avgPriceMinor: BigInt! + """ + Currency code. + """ + currency: CurrencyCode! } -type Order @join__type(graph: ORDERS_ADMIN) { - id: ID! - createdBy: OrderActor! - status: OrderStatus! - number: BigInt! - currencyCode: String! - subtotal: BigInt! - shippingTotal: BigInt - discountTotal: BigInt - taxTotal: BigInt - grandTotal: BigInt! - customerIdentity: OrderCustomerIdentity! - customerStatistic: OrderCustomerStatistic! - adminNote: String - customerNote: String - events: [OrderEvent!]! - tags: [Tag!]! - labels: [Label!]! - lines: [OrderLine!]! - createdAt: DateTime! - updatedAt: DateTime! - deletedAt: DateTime +""" +Pricing widget payload with current price, cost, history and statistics. +""" +type PricingWidgetPayload @join__type(graph: CATALOG_ADMIN) { + """ + Current active price. + """ + currentPrice: VariantPrice + """ + Current active cost. + """ + currentCostPrice: VariantCost + """ + Price history for the period. + """ + history: VariantPriceConnection! + """ + Computed statistics for the period. + """ + statistics: VariantPriceHistoryStatistics! } -type OrderLine @join__type(graph: ORDERS_ADMIN) { - id: ID! - quantity: Int! - unitPrice: Int! - unitComparePrice: Int! - discountAmount: Int! - subtotalAmount: Int! - taxAmount: Int - totalAmount: Int! - purchasableId: ID! - createdAt: DateTime! - updatedAt: DateTime! +""" +Payload for variant pricing update. +""" +type VariantUpdatePricingPayload @join__type(graph: CATALOG_ADMIN) { + """ + The updated variant. + """ + variant: Variant + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! } -type OrderDeliveryAddress @join__type(graph: ORDERS_ADMIN) { - id: ID! - address1: String! - address2: String - city: String! - countryCode: CountryCode! - provinceCode: String - postalCode: String - email: Email - firstName: String - lastName: String - data: JSON -} - -type OrderCustomerStatistic @join__type(graph: ORDERS_ADMIN) { - totalRevenue: Int! - totalGuestOrders: Int! - totalAuthorizedOrders: Int! -} - -type OrderEvent @join__type(graph: ORDERS_ADMIN) { - id: String! - performedBy: OrderActor! - eventType: OrderEventType! - data: JSON - metadata: JSON - createdAt: DateTime! -} - -type OrdersOutput @join__type(graph: ORDERS_ADMIN) { - data: [Order!]! - meta: CollectionMeta! -} - -type OrderQuery @join__type(graph: ORDERS_ADMIN) { - orders(input: OrdersInput) : OrdersOutput! - order(id: ID!) : Order -} - -type OrderMutation @join__type(graph: ORDERS_ADMIN) { - orderAdminNoteUpdate(input: OrderAdminNoteUpdateInput!) : Boolean! - orderCommentAdd(input: OrderCommentAddInput!) : Boolean! - orderCancel(input: OrderCancelInput!) : Boolean! - orderClose(input: OrderCloseInput!) : Boolean! -} - -type Weight @join__type(graph: ORDERS_ADMIN) { - weight: Float! - unit: WeightUnit! -} - -type CollectionMeta @join__type(graph: ORDERS_ADMIN) { - page: Int! - pageSize: Int! - count: Int! - total: Int! - pageCount: Int! -} - -type PurchasableSnapshot implements Purchasable @join__type(graph: ORDERS_ADMIN) @join__implements(graph: ORDERS_ADMIN, interface: "Purchasable") { - id: ID! - purchasableSnapshot: JSON! -} - -""" -API key for programmatic access to the project -""" -type ApiKey @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) @join__type(graph: PROJECT_ADMIN, key: "id") { - """ - Unique identifier of the API key - """ - id: ID! - """ - Human-readable name for the API key +type ProductPriceRange @join__type(graph: CATALOG_ADMIN) { """ - name: String! @join__field(graph: PROJECT_ADMIN) - """ - The API key value (only shown once upon creation) - """ - key: String! @join__field(graph: PROJECT_ADMIN) - """ - Timestamp when the API key was created + Minimum product price amount in minor units. """ - createdAt: DateTime! @join__field(graph: PROJECT_ADMIN) + minPriceAmount: BigInt! """ - ID of the user who created this API key + Maximum product price amount in minor units. """ - createdById: ID! @join__field(graph: PROJECT_ADMIN) + maxPriceAmount: BigInt! """ - Optional expiration date for the API key + Currency code used for the returned price amounts. """ - dueDate: DateTime @join__field(graph: PROJECT_ADMIN) + currency: CurrencyCode! +} + +""" +Rich text content in multiple formats. +""" +type RichText @join__type(graph: CATALOG_ADMIN) { """ - Timestamp of the last API call using this key + Plain text content. """ - lastUsedAt: DateTime @join__field(graph: PROJECT_ADMIN) + text: String! """ - Whether the API key has been banned by the system + HTML content. """ - isBanned: Boolean! @join__field(graph: PROJECT_ADMIN) + html: String! """ - Timestamp when the API key was revoked, null if still active + EditorJS JSON content. """ - revokedAt: DateTime @join__field(graph: PROJECT_ADMIN) -} - -type Customer @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) { - id: ID! + json: JSON! } -type Label @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) { - id: ID! +type ProductCategoryAssignment @join__type(graph: CATALOG_ADMIN) { + category: Category! + isPrimary: Boolean! } -type Tag @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) { +""" +A vendor represents the supplier or brand owner associated with a product. +""" +type Vendor implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { + """ + The globally unique ID of the vendor. + """ id: ID! + """ + The display name of the vendor. + """ + name: String! } """ -Payload returned after creating an API key +A connection to a list of Vendor items. """ -type ApiKeyCreatePayload @join__type(graph: PROJECT_ADMIN) { +type VendorConnection @join__type(graph: CATALOG_ADMIN) { """ - The newly created API key, null if creation failed + A list of edges. """ - apiKey: ApiKey + edges: [VendorEdge!]! """ - List of errors that occurred during creation + Information to aid in pagination. """ - userErrors: [UserError!]! + pageInfo: PageInfo! + """ + The total number of vendors. + """ + totalCount: Int! } """ -Payload returned after an API key action (revoke) +An edge in a Vendor connection. """ -type ApiKeyActionPayload @join__type(graph: PROJECT_ADMIN) { +type VendorEdge @join__type(graph: CATALOG_ADMIN) { """ - Whether the action was successful + The item at the end of the edge. """ - success: Boolean! + node: Vendor! """ - List of errors that occurred during the action + A cursor for use in pagination. """ - userErrors: [UserError!]! + cursor: String! } """ -Payload returned after deleting an API key +Payload for vendor creation. """ -type ApiKeyDeletePayload @join__type(graph: PROJECT_ADMIN) { +type VendorCreatePayload @join__type(graph: CATALOG_ADMIN) { """ - ID of the deleted API key, null if deletion failed + The created vendor. """ - deletedApiKeyId: ID + vendor: Vendor """ - List of errors that occurred during deletion + List of errors that occurred during the mutation. """ - userErrors: [UserError!]! + userErrors: [GenericUserError!]! } """ -Queries for store management +A product represents an item that can be sold. """ -type StoreQuery @join__type(graph: PROJECT_ADMIN) { +type Product implements Node & Listing @join__type(graph: CATALOG_ADMIN, key: "id") @join__type(graph: LISTING_ADMIN, key: "id", resolvable: false, extension: true) @join__implements(graph: CATALOG_ADMIN, interface: "Node") @join__implements(graph: LISTING_ADMIN, interface: "Node") @join__implements(graph: LISTING_ADMIN, interface: "Listing") { """ - Get all stores accessible to the current user in the organization + The Product global ID. """ - stores(""" - Organization ID to list stores from + id: ID! """ - organizationId: ID!) : [Store!]! + Product discriminator. """ - Get the current store from context + kind: ProductKind! @join__field(graph: CATALOG_ADMIN) """ - currentStore: Store + The URL-friendly handle for the product. """ - Get all API keys for the current store + handle: String! @join__field(graph: CATALOG_ADMIN) """ - apiKeys: [ApiKey!]! -} - -""" -Mutations for store management -""" -type StoreMutation @join__type(graph: PROJECT_ADMIN) { + The date and time when the product was published, or null if unpublished. """ - Create a new store + publishedAt: DateTime @join__field(graph: CATALOG_ADMIN) """ - storeCreate(input: StoreCreateInput!) : StoreCreatePayload! + Whether the product is currently published. """ - Update an existing store + isPublished: Boolean! @join__field(graph: CATALOG_ADMIN) """ - storeUpdate(input: StoreUpdateInput!) : StoreUpdatePayload! + The date and time when the product was created. """ - Delete a store + createdAt: DateTime! @join__field(graph: CATALOG_ADMIN) """ - storeDelete(input: StoreDeleteInput!) : StoreDeletePayload! + The date and time when the product was last updated. """ - Add a new currency to the store + updatedAt: DateTime! @join__field(graph: CATALOG_ADMIN) """ - currencyCreate(input: CurrencyCreateInput!) : CurrencyCreatePayload! + The date and time when the product was deleted (soft delete). """ - Remove a currency from the store + deletedAt: DateTime @join__field(graph: CATALOG_ADMIN) """ - currencyDelete(input: CurrencyDeleteInput!) : CurrencyDeletePayload! + Optimistic locking revision number. Incremented on each update. """ - Set the default currency for the store + revision: Int! @join__field(graph: CATALOG_ADMIN) """ - currencySetDefault(input: CurrencySetDefaultInput!) : CurrencyUpdatePayload! + The vendor associated with this product. """ - Add a new locale to the store + vendor: Vendor @join__field(graph: CATALOG_ADMIN) """ - localeCreate(input: LocaleCreateInput!) : LocaleCreatePayload! + The variants of this product. """ - Remove a locale from the store + variants(first: Int, after: String, last: Int, before: String) : VariantConnection! @join__field(graph: CATALOG_ADMIN) """ - localeDelete(input: LocaleDeleteInput!) : LocaleDeletePayload! + Media registered on this product. """ - Set the default locale for the store + media: [ProductMediaItem!]! @join__field(graph: CATALOG_ADMIN) """ - localeSetDefault(input: LocaleSetDefaultInput!) : LocaleUpdatePayload! + The options available for this product. """ - Create a new API key for programmatic access + options: [ProductOption!]! @join__field(graph: CATALOG_ADMIN) """ - apiKeyCreate(input: ApiKeyCreateInput!) : ApiKeyCreatePayload! + The features of this product. """ - Revoke an API key (soft delete) + features: [ProductFeature!]! @join__field(graph: CATALOG_ADMIN) """ - apiKeyRevoke(input: ApiKeyRevokeInput!) : ApiKeyActionPayload! + The total number of variants for this product. """ - Permanently delete an API key + variantsCount: Int! @join__field(graph: CATALOG_ADMIN) """ - apiKeyDelete(input: ApiKeyDeleteInput!) : ApiKeyDeletePayload! -} - -""" -Currency configuration for the project -""" -type Currency @join__type(graph: PROJECT_ADMIN) { + The primary category assigned to this product. """ - ISO 4217 currency code + primaryCategory: Category @join__field(graph: CATALOG_ADMIN) """ - code: CurrencyCode! + Category assignments with relationship metadata. """ - Display name of the currency + categoryAssignments: [ProductCategoryAssignment!]! @join__field(graph: CATALOG_ADMIN) """ - name: String! + The tags associated with this product. """ - Whether this currency is currently active for the project + tags: [Tag!]! @join__field(graph: CATALOG_ADMIN) """ - isActive: Boolean! + Product title. """ - Exchange rate relative to the base currency + title: String! @join__field(graph: CATALOG_ADMIN) """ - exchangeRate: ExchangeRate! + Product description. + """ + description: RichText @join__field(graph: CATALOG_ADMIN) + """ + Short excerpt. + """ + excerpt: RichText @join__field(graph: CATALOG_ADMIN) + """ + SEO and Open Graph metadata. + """ + seo: ProductSeo @join__field(graph: CATALOG_ADMIN) + """ + Current product price range in the selected currency. + """ + priceRange: ProductPriceRange @join__field(graph: CATALOG_ADMIN) } """ -Exchange rate representation using integer arithmetic for precision +A connection to a list of Product items. """ -type ExchangeRate @join__type(graph: PROJECT_ADMIN) { +type ProductConnection @join__type(graph: CATALOG_ADMIN) { """ - The exchange rate value as an integer (divide by 10^scale for actual rate) + A list of edges. """ - amount: Int! + edges: [ProductEdge!]! """ - The number of decimal places in the amount + Information to aid in pagination. """ - scale: Int! + pageInfo: PageInfo! + """ + The total number of products. + """ + totalCount: Int! } """ -Payload returned after creating a currency +An edge in a Product connection. """ -type CurrencyCreatePayload @join__type(graph: PROJECT_ADMIN) { +type ProductEdge @join__type(graph: CATALOG_ADMIN) { """ - The newly created currency, null if creation failed + The item at the end of the edge. """ - currency: Currency + node: Product! """ - List of errors that occurred during creation + A cursor for use in pagination. """ - userErrors: [UserError!]! + cursor: String! } """ -Payload returned after deleting a currency +Payload for product creation. """ -type CurrencyDeletePayload @join__type(graph: PROJECT_ADMIN) { +type ProductCreatePayload @join__type(graph: CATALOG_ADMIN) { """ - The code of the deleted currency, null if deletion failed + The created product. """ - deletedCurrencyCode: CurrencyCode + product: Product """ - List of errors that occurred during deletion + List of errors that occurred during the mutation. """ - userErrors: [UserError!]! + userErrors: [GenericUserError!]! } """ -Payload returned after updating currency settings +Payload for product deletion. """ -type CurrencyUpdatePayload @join__type(graph: PROJECT_ADMIN) { +type ProductDeletePayload @join__type(graph: CATALOG_ADMIN) { """ - Whether the update was successful + The ID of the deleted product. """ - success: Boolean! + deletedProductId: ID """ - List of errors that occurred during update + List of errors that occurred during the mutation. """ - userErrors: [UserError!]! + userErrors: [GenericUserError!]! } """ -Locale configuration for the project +Result of a single operation in the unified update. """ -type Locale @join__type(graph: PROJECT_ADMIN) { +type OperationResult @join__type(graph: CATALOG_ADMIN) { """ - BCP 47 locale code + The type of operation. """ - code: LocaleCode! + type: OperationType! """ - Display name of the locale + Whether the operation was applied successfully. """ - name: String! + applied: Boolean! """ - Whether this locale is currently active for the project + Per-request client correlation key for create operations. """ - isActive: Boolean! + clientMutationId: String + """ + Entity affected by this operation. + """ + entityId: ID + """ + Errors that occurred during this operation. + """ + errors: [GenericUserError!]! } """ -Payload returned after creating a locale +Payload for the unified product update mutation. """ -type LocaleCreatePayload @join__type(graph: PROJECT_ADMIN) { +type ProductUpdatePayload @join__type(graph: CATALOG_ADMIN) { """ - The newly created locale, null if creation failed + The updated product with new revision. """ - locale: Locale + product: Product """ - List of errors that occurred during creation + Results of each operation. """ - userErrors: [UserError!]! + operationResults: [OperationResult!]! + """ + All errors from all operations. + """ + userErrors: [GenericUserError!]! } """ -Payload returned after deleting a locale +Information about pagination in a connection. """ -type LocaleDeletePayload @join__type(graph: PROJECT_ADMIN) { +type PageInfo @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) { """ - The code of the deleted locale, null if deletion failed + When paginating forwards, are there more items? """ - deletedLocaleCode: LocaleCode + hasNextPage: Boolean! """ - List of errors that occurred during deletion + When paginating backwards, are there more items? """ - userErrors: [UserError!]! + hasPreviousPage: Boolean! + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String } """ -Payload returned after updating locale settings +A generic user error type for mutation responses. """ -type LocaleUpdatePayload @join__type(graph: PROJECT_ADMIN) { +type GenericUserError implements UserError @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: PROJECT_ADMIN) @join__implements(graph: CATALOG_ADMIN, interface: "UserError") @join__implements(graph: IAM_ADMIN, interface: "UserError") @join__implements(graph: LISTING_ADMIN, interface: "UserError") @join__implements(graph: MEDIA_ADMIN, interface: "UserError") @join__implements(graph: PROJECT_ADMIN, interface: "UserError") { """ - Whether the update was successful + Human-readable error message """ - success: Boolean! + message: String! """ - List of errors that occurred during update + Path to the field that caused the error """ - userErrors: [UserError!]! + field: [String!] + """ + Machine-readable error code + """ + code: String } """ -A store +SEO and Open Graph metadata. """ -type Store @join__type(graph: PROJECT_ADMIN, key: "id") { +type Seo @join__type(graph: CATALOG_ADMIN) { """ - Unique identifier of the store + SEO title for search engines (max 70 chars). + """ + seoTitle: String + """ + SEO description for search engines (max 160 chars). + """ + seoDescription: String + """ + Open Graph title for social media sharing (max 95 chars). + """ + ogTitle: String + """ + Open Graph description for social media sharing. + """ + ogDescription: String + """ + Open Graph image for social media sharing. + """ + ogImage: File +} + +""" +SEO and Open Graph metadata for a product. +""" +type ProductSeo @join__type(graph: CATALOG_ADMIN) { + seoTitle: String + seoDescription: String + ogTitle: String + ogDescription: String + ogImage: File +} + +""" +A warehouse represents a physical location where inventory is stored. +""" +type Warehouse implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { + """ + The globally unique ID of the warehouse. """ id: ID! """ - Organization that owns this store (federation reference) + The unique code identifying this warehouse. """ - organization: Organization + code: String! """ - URL-friendly unique identifier + The display name of the warehouse. """ name: String! """ - Display name of the store + Whether this is the default warehouse for the project. """ - displayName: String! + isDefault: Boolean! """ - Current operational status of the store + The date and time when the warehouse was created. """ - status: StoreStatus! + createdAt: DateTime! """ - IANA timezone identifier for the store + The date and time when the warehouse was last updated. """ - timezone: String! + updatedAt: DateTime! """ - Contact email address for the store + Stock levels for all variants in this warehouse. """ - email: String + stock( + first: Int + after: String + last: Int + before: String + """ + Filter conditions + """ + where: WarehouseStockWhereInput + """ + Sort order + """ + orderBy: [WarehouseStockOrderByInput!] + ): WarehouseStockConnection! """ - List of enabled locale codes for the store + Total number of variants stocked in this warehouse. """ - locales: [LocaleCode!]! + variantsCount: Int! +} + +""" +A connection to a list of Warehouse items. +""" +type WarehouseConnection @join__type(graph: CATALOG_ADMIN) { """ - List of enabled currency codes for the store + A list of edges. """ - currencies: [CurrencyCode!]! + edges: [WarehouseEdge!]! """ - Base currency used for exchange rate calculations + Information to aid in pagination. """ - baseCurrency: CurrencyCode! + pageInfo: PageInfo! """ - Default locale for new content + The total number of warehouses. """ - defaultLocale: LocaleCode! + totalCount: Int! +} + +""" +An edge in a Warehouse connection. +""" +type WarehouseEdge @join__type(graph: CATALOG_ADMIN) { """ - Default currency for pricing display + The item at the end of the edge. """ - defaultCurrency: CurrencyCode! + node: Warehouse! """ - Default unit for product weights + A cursor for use in pagination. """ - defaultWeightUnit: WeightUnit! + cursor: String! +} + +""" +Represents stock level for a variant in a specific warehouse. +""" +type WarehouseStock implements Node @join__type(graph: CATALOG_ADMIN) @join__implements(graph: CATALOG_ADMIN, interface: "Node") { """ - Default unit for product dimensions + The globally unique ID of the stock record. """ - defaultDimensionUnit: DimensionUnit! + id: ID! """ - Timestamp when the store was created + The globally unique ID of the warehouse this stock belongs to. + """ + warehouseId: ID! + """ + The globally unique ID of the variant this stock belongs to. + """ + variantId: ID! + """ + The warehouse where this stock is located. + """ + warehouse: Warehouse! + """ + The variant this stock record is for. + """ + variant: Variant! + """ + The quantity currently on hand. + """ + quantityOnHand: Int! + """ + The quantity currently reserved. + """ + reservedQuantity: Int! + """ + The quantity currently unavailable. + """ + unavailableQuantity: Int! + """ + The quantity available for sale. + """ + availableForSale: Int! + """ + The date and time when the stock was created. """ createdAt: DateTime! """ - Timestamp when the store was last updated + The date and time when the stock was last updated. """ updatedAt: DateTime! +} + +""" +A connection to a list of WarehouseStock items. +""" +type WarehouseStockConnection @join__type(graph: CATALOG_ADMIN) { """ - Membership info (resolved from IAM by domain) + A list of edges. """ - membership: Membership! + edges: [WarehouseStockEdge!]! + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + The total number of stock records. + """ + totalCount: Int! } """ -Payload returned after creating a store +An edge in a WarehouseStock connection. """ -type StoreCreatePayload @join__type(graph: PROJECT_ADMIN) { +type WarehouseStockEdge @join__type(graph: CATALOG_ADMIN) { """ - The newly created store, null if creation failed + The item at the end of the edge. """ - store: Store + node: WarehouseStock! """ - List of errors that occurred during creation + A cursor for use in pagination. """ - userErrors: [UserError!]! + cursor: String! } """ -Payload returned after updating a store +Payload for warehouse creation. """ -type StoreUpdatePayload @join__type(graph: PROJECT_ADMIN) { +type WarehouseCreatePayload @join__type(graph: CATALOG_ADMIN) { """ - The updated store, null if update failed + The created warehouse. """ - store: Store + warehouse: Warehouse """ - List of errors that occurred during update + List of errors that occurred during the mutation. """ - userErrors: [UserError!]! + userErrors: [GenericUserError!]! } """ -Payload returned after deleting a store +Payload for warehouse update. """ -type StoreDeletePayload @join__type(graph: PROJECT_ADMIN) { +type WarehouseUpdatePayload @join__type(graph: CATALOG_ADMIN) { + """ + The updated warehouse. + """ + warehouse: Warehouse + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for warehouse deletion. +""" +type WarehouseDeletePayload @join__type(graph: CATALOG_ADMIN) { + """ + The ID of the deleted warehouse. + """ + deletedWarehouseId: ID + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for warehouse stock creation. +""" +type WarehouseStockCreatePayload @join__type(graph: CATALOG_ADMIN) { + """ + The created warehouse stock records. + """ + warehouseStocks: [WarehouseStock!]! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for warehouse stock deletion. +""" +type WarehouseStockDeletePayload @join__type(graph: CATALOG_ADMIN) { + """ + The IDs of the deleted warehouse stock records. + """ + deletedWarehouseStockIds: [ID!]! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +A tag represents a simple label for organizing and filtering products. +""" +type Tag implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) @join__implements(graph: CATALOG_ADMIN, interface: "Node") { + """ + The globally unique ID of the tag. + """ + id: ID! + """ + The URL-friendly handle for the tag. + """ + handle: String! @join__field(graph: CATALOG_ADMIN) + """ + The date and time when the tag was created. + """ + createdAt: DateTime! @join__field(graph: CATALOG_ADMIN) + """ + The display name of the tag. + """ + name: String! @join__field(graph: CATALOG_ADMIN) + """ + The total number of products with this tag. + """ + productsCount: Int! @join__field(graph: CATALOG_ADMIN) +} + +""" +A connection to a list of Tag items. +""" +type TagConnection @join__type(graph: CATALOG_ADMIN) { + """ + A list of edges. + """ + edges: [TagEdge!]! + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + The total number of tags. + """ + totalCount: Int! +} + +""" +An edge in a Tag connection. +""" +type TagEdge @join__type(graph: CATALOG_ADMIN) { + """ + The item at the end of the edge. + """ + node: Tag! + """ + A cursor for use in pagination. + """ + cursor: String! +} + +""" +Payload for tag creation. +""" +type TagCreatePayload @join__type(graph: CATALOG_ADMIN) { + """ + The created tag. + """ + tag: Tag + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for tag update. +""" +type TagUpdatePayload @join__type(graph: CATALOG_ADMIN) { + """ + The updated tag. + """ + tag: Tag + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for tag deletion. +""" +type TagDeletePayload @join__type(graph: CATALOG_ADMIN) { + """ + The ID of the deleted tag. + """ + deletedTagId: ID + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +A variant represents a specific version of a product, such as a size or color. +Catalog Service owns this type. +Inventory fields (sku, dimensions, weight, cost, stock) are resolved by Catalog. +""" +type Variant implements Node @join__type(graph: CATALOG_ADMIN, key: "id") @join__implements(graph: CATALOG_ADMIN, interface: "Node") { + """ + The globally unique ID of the variant. + """ + id: ID! + """ + Variant discriminator. Must match parent product kind. + """ + kind: ProductKind! + """ + The product this variant belongs to. + """ + product: Product! + """ + Whether this is the default variant for the product. + """ + isDefault: Boolean! + """ + The URL-friendly handle for the variant (generated from options). + """ + handle: String! + """ + The external system identifier for integration purposes. + """ + externalSystem: String + """ + The external ID in the external system. + """ + externalId: String + """ + The date and time when the variant was created. + """ + createdAt: DateTime! + """ + The date and time when the variant was last updated. + """ + updatedAt: DateTime! + """ + The date and time when the variant was deleted (soft delete). + """ + deletedAt: DateTime + """ + Current price for this variant. + """ + price: VariantPrice + """ + Price history for this variant. + """ + priceHistory(first: Int, after: String, last: Int, before: String) : VariantPriceConnection! + """ + The selected option values for this variant. + """ + selectedOptions: [SelectedOption!]! + """ + Variant title. + """ + title: String + """ + Media attached to this variant (images, videos). + """ + media: [VariantMediaItem!]! + """ + Physical dimensions (stored in millimeters). + """ + dimensions: VariantDimensions + """ + Physical weight (stored in grams). + """ + weight: VariantWeight + """ + Inventory item associated with this variant. + """ + inventoryItem: InventoryItem + """ + Bundle configuration assigned to this variant. Null for BASE variants. + """ + bundleConfiguration: BundleConfiguration +} + +""" +A connection to a list of Variant items. +""" +type VariantConnection @join__type(graph: CATALOG_ADMIN) { + """ + A list of edges. + """ + edges: [VariantEdge!]! + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + The total number of variants. + """ + totalCount: Int! +} + +""" +An edge in a Variant connection. +""" +type VariantEdge @join__type(graph: CATALOG_ADMIN) { + """ + The item at the end of the edge. + """ + node: Variant! + """ + A cursor for use in pagination. + """ + cursor: String! +} + +""" +Payload for variant creation. +""" +type VariantCreatePayload @join__type(graph: CATALOG_ADMIN) { + """ + The created variant. + """ + variant: Variant + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for variant options update. +""" +type VariantUpdateOptionsPayload @join__type(graph: CATALOG_ADMIN) { + """ + The updated variant. + """ + variant: Variant + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for variant deletion. +""" +type VariantDeletePayload @join__type(graph: CATALOG_ADMIN) { + """ + The ID of the deleted variant. + """ + deletedVariantId: ID + """ + The product the variant belonged to. + """ + product: Product + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +A file represents a stored media asset. +""" +type File implements Node @join__type(graph: CATALOG_ADMIN, key: "id", resolvable: false, extension: true) @join__type(graph: IAM_ADMIN, key: "id", resolvable: false, extension: true) @join__type(graph: LISTING_ADMIN, key: "id", resolvable: false, extension: true) @join__type(graph: MEDIA_ADMIN, key: "id") @join__implements(graph: MEDIA_ADMIN, interface: "Node") { + """ + The globally unique ID of the file. + """ + id: ID! + """ + Provider type (s3, youtube, vimeo, url, local). + """ + provider: FileProvider! @join__field(graph: MEDIA_ADMIN) + """ + Public URL to access file. + """ + url: String! @join__field(graph: MEDIA_ADMIN) + """ + MIME type. + """ + mimeType: String @join__field(graph: MEDIA_ADMIN) + """ + File extension. + """ + ext: String @join__field(graph: MEDIA_ADMIN) + """ + Size in bytes (0 for external providers). + """ + sizeBytes: BigInt! @join__field(graph: MEDIA_ADMIN) + """ + Original filename from upload. + """ + originalName: String @join__field(graph: MEDIA_ADMIN) + """ + Image/video dimensions (null if not applicable). + """ + dimensions: MediaDimensions @join__field(graph: MEDIA_ADMIN) + """ + Duration in milliseconds (for video/audio). + """ + durationMs: Int @join__field(graph: MEDIA_ADMIN) + """ + Alt text for accessibility. + """ + altText: String @join__field(graph: MEDIA_ADMIN) + """ + Source URL (for files uploaded from URL). + """ + sourceUrl: String @join__field(graph: MEDIA_ADMIN) + """ + Whether the file has been processed. + """ + isProcessed: Boolean! @join__field(graph: MEDIA_ADMIN) + """ + Additional metadata. + """ + meta: JSON @join__field(graph: MEDIA_ADMIN) + """ + The date and time when the file was created. + """ + createdAt: DateTime! @join__field(graph: MEDIA_ADMIN) + """ + The date and time when the file was last updated. + """ + updatedAt: DateTime! @join__field(graph: MEDIA_ADMIN) + """ + The date and time when the file was deleted (soft delete). + """ + deletedAt: DateTime @join__field(graph: MEDIA_ADMIN) + """ + Current deletion state (ACTIVE, SOFT_DELETED, DELETING). + """ + deletionState: String! @join__field(graph: MEDIA_ADMIN) + """ + Deletion error code, if any. + """ + deletionErrorCode: String @join__field(graph: MEDIA_ADMIN) + """ + Last deletion error details. + """ + lastDeletionError: String @join__field(graph: MEDIA_ADMIN) + """ + The date and time when the last deletion error occurred. + """ + failedAt: DateTime @join__field(graph: MEDIA_ADMIN) + """ + S3-specific data (only for S3 provider). + """ + s3Data: S3ObjectData @join__field(graph: MEDIA_ADMIN) + """ + External media data (for YouTube, Vimeo, etc). + """ + externalData: ExternalMediaData @join__field(graph: MEDIA_ADMIN) + """ + Usage summary for this file. + """ + usage: FileUsageSummary! @join__field(graph: MEDIA_ADMIN) +} + +""" +Authentication tokens. +""" +type AuthTokenPayload @join__type(graph: IAM_ADMIN) { + """ + Access token for API requests. + """ + accessToken: String! + """ + Refresh token for obtaining new access tokens. + """ + refreshToken: String! + """ + Expiration time in seconds. + """ + expiresIn: Int! +} + +type UserQuery @join__type(graph: IAM_ADMIN) { + """ + Get current authenticated admin user + """ + current: User + """ + Get all active sessions for the current user. + """ + mySessions: [Session!]! + """ + Check authorization for current user. + Used for server-side permission checks. + For client-side checks, use project.roles + user.role. + """ + authorize(input: AuthorizeInput!) : AuthorizePayload! +} + +type UserMutation @join__type(graph: IAM_ADMIN) { + userUpdateProfile(input: UserUpdateProfileInput!) : UserUpdateProfilePayload! + userUpdateEmail(input: UserUpdateEmailInput!) : UserUpdateEmailPayload! + userUpdatePassword(input: UserUpdatePasswordInput!) : UserUpdatePasswordPayload! + """ + Revoke a specific session by ID. + """ + sessionRevoke(input: SessionRevokeInput!) : SessionRevokePayload! + """ + Revoke all sessions except the current one. + """ + sessionRevokeAll: SessionRevokeAllPayload! +} + +type AuthMutation @join__type(graph: IAM_ADMIN) { + signUp(input: UserSignUpInput!) : UserSignUpPayload! + signIn(input: UserSignInInput!) : UserSignInPayload! + signOut(input: UserSignOutInput!) : UserSignOutPayload! + tokenRefresh(input: UserTokenRefreshInput!) : UserTokenRefreshPayload! +} + +""" +Role mutations. +""" +type RoleMutation @join__type(graph: IAM_ADMIN) { + """ + Create custom role. + Requires: project:admin permission. + """ + roleCreate(input: RoleCreateInput!) : RoleCreatePayload! + """ + Update role. + Requires: project:admin permission. + System roles cannot be modified. + """ + roleUpdate(input: RoleUpdateInput!) : RoleUpdatePayload! + """ + Delete custom role. + Requires: project:admin permission. + System roles cannot be deleted. + Roles with assigned users cannot be deleted. + """ + roleDelete(input: RoleDeleteInput!) : RoleDeletePayload! +} + +""" +Member with role assignment. +Used for both org-level (domain = "org") and store-level (domain = "store:uuid"). +""" +type Member @join__type(graph: IAM_ADMIN, key: "id") { + """ + Unique identifier. + """ + id: ID! + """ + User reference. + """ + user: User! + """ + Role name. + """ + role: String! + """ + Whether this member is the organization owner. + Owner bypasses all authorization checks within the organization. + Only applicable for org-level membership (domain = "org"). + """ + isOwner: Boolean! + """ + When access was granted. + """ + grantedAt: DateTime! + """ + User who granted access. + """ + grantedBy: User +} + +""" +Membership — universal container for members and roles. +Used for both Organization and Store. +Domain determines context: orgId for org-level, storeId for store-level. +""" +type Membership @join__type(graph: IAM_ADMIN, key: "domain organizationId") @join__type(graph: PROJECT_ADMIN, key: "domain organizationId", extension: true) { + """ + Domain identifier ("org" for organization, or "store:uuid"). + """ + domain: String! + """ + Organization ID (required for casbin queries). + """ + organizationId: ID! + """ + All roles available in this organization. + """ + roles: [Role!]! @join__field(graph: IAM_ADMIN) + """ + All members with access to this domain. + """ + members: [Member!]! @join__field(graph: IAM_ADMIN) + """ + Available resources for role editor (org-level only). + """ + availableResources: [ResourceDefinition!] @join__field(graph: IAM_ADMIN) +} + +""" +Organization - top level entity for multi-tenancy. +Users belong to organizations, organizations contain stores. +""" +type Organization implements Node @join__type(graph: IAM_ADMIN, key: "id") @join__type(graph: PROJECT_ADMIN, key: "id", extension: true) @join__implements(graph: IAM_ADMIN, interface: "Node") { + """ + Unique identifier. + """ + id: ID! + """ + URL-friendly unique identifier. + """ + name: String! @join__field(graph: IAM_ADMIN) + """ + Display name (e.g., "Acme Corp"). + """ + displayName: String! @join__field(graph: IAM_ADMIN) + """ + Organization logo (from Media service). + """ + logo: File @join__field(graph: IAM_ADMIN) + """ + Membership info (members + roles). Domain = orgId. + """ + membership: Membership! @join__field(graph: IAM_ADMIN) + """ + Timestamp when the organization was created. + """ + createdAt: DateTime! @join__field(graph: IAM_ADMIN) + """ + Timestamp when the organization was last updated. + """ + updatedAt: DateTime @join__field(graph: IAM_ADMIN) +} + +""" +A connection to a list of Organization items. +""" +type OrganizationConnection @join__type(graph: IAM_ADMIN) { + """ + A list of edges. + """ + edges: [OrganizationEdge!]! + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + The total number of organizations. + """ + totalCount: Int! +} + +""" +An edge in an Organization connection. +""" +type OrganizationEdge @join__type(graph: IAM_ADMIN) { + """ + The item at the end of the edge. + """ + node: Organization! + """ + A cursor for use in pagination. + """ + cursor: String! +} + +type OrganizationCreatePayload @join__type(graph: IAM_ADMIN) { + organization: Organization + userErrors: [GenericUserError!]! +} + +type OrganizationUpdatePayload @join__type(graph: IAM_ADMIN) { + organization: Organization + userErrors: [GenericUserError!]! +} + +type OrganizationDeletePayload @join__type(graph: IAM_ADMIN) { + deletedOrganizationId: ID + userErrors: [GenericUserError!]! +} + +type MemberInvitePayload @join__type(graph: IAM_ADMIN) { + member: Member + userErrors: [GenericUserError!]! +} + +type MemberRemovePayload @join__type(graph: IAM_ADMIN) { + removedMemberId: ID + userErrors: [GenericUserError!]! +} + +type MemberRoleChangePayload @join__type(graph: IAM_ADMIN) { + member: Member + userErrors: [GenericUserError!]! +} + +type MemberAccessRemovePayload @join__type(graph: IAM_ADMIN) { + success: Boolean! + userErrors: [GenericUserError!]! +} + +type OwnershipTransferPayload @join__type(graph: IAM_ADMIN) { + """ + Whether the transfer was successful. + """ + success: Boolean! + userErrors: [GenericUserError!]! +} + +""" +Organization queries. +""" +type OrganizationQuery @join__type(graph: IAM_ADMIN) { + """ + Get organization by ID or name (if user has access). + Provide either id or name, not both. + """ + organization(id: ID, name: String) : Organization + """ + Get all organizations the current user has access to with cursor pagination. + Returns empty connection if not authenticated. + """ + organizations( + """ + Returns the first n items + """ + first: Int + """ + Returns items after this cursor + """ + after: String + """ + Returns the last n items + """ + last: Int + """ + Returns items before this cursor + """ + before: String + """ + Filter conditions + """ + where: OrganizationWhereInput + """ + Sort order + """ + orderBy: [OrganizationOrderByInput!] + ): OrganizationConnection! +} + +""" +Organization mutations. +""" +type OrganizationMutation @join__type(graph: IAM_ADMIN) { + """ + Create a new organization. + Current user becomes the owner. + """ + organizationCreate(input: OrganizationCreateInput!) : OrganizationCreatePayload! + """ + Update organization. + Requires: org admin or owner. + """ + organizationUpdate(input: OrganizationUpdateInput!) : OrganizationUpdatePayload! + """ + Delete organization. Requires: org owner only. + """ + organizationDelete(id: ID!) : OrganizationDeletePayload! + """ + Transfer organization ownership to another admin. + Only the current owner can transfer ownership. + New owner must have admin role in the organization. + Previous owner retains admin role. + """ + ownershipTransfer(input: OwnershipTransferInput!) : OwnershipTransferPayload! + """ + Invite member to organization with role assignments. + """ + memberInvite(input: MemberInviteInput!) : MemberInvitePayload! + """ + Remove member from organization. + Requires: org admin or owner. + Cannot remove owner (transfer ownership first). + """ + memberRemove(input: MemberRemoveInput!) : MemberRemovePayload! + """ + Change role for a member in specific domain. + Owner cannot be demoted. + """ + memberRoleChange(input: MemberRoleChangeInput!) : MemberRoleChangePayload! + """ + Remove member's access from domain. + """ + memberAccessRemove(input: MemberAccessRemoveInput!) : MemberAccessRemovePayload! +} + +""" +Role with permissions - universal, can be assigned at any level. +""" +type Role @join__type(graph: IAM_ADMIN, key: "id") { + """ + Unique identifier. + """ + id: ID! + """ + Domain scope for this role. + - "org" = organization-level role + - "store:{uuid}" = store-specific role + """ + domain: String! + """ + Unique role name within organization (e.g.: admin, manager, viewer). + """ + name: String! + """ + Human-readable display name. + """ + displayName: String! + """ + Role description. + """ + description: String + """ + System role cannot be deleted or modified. + """ + isSystem: Boolean! + """ + Role permissions. + """ + permissions: [RolePermission!]! + """ + Role creation date. + """ + createdAt: DateTime + """ + Role last update date. + """ + updatedAt: DateTime +} + +""" +Role permission - access to resource with specific actions. +""" +type RolePermission @join__type(graph: IAM_ADMIN) { + """ + Resource name (e.g.: org.profile, store.members). + """ + resource: String! + """ + Allowed actions (e.g.: create, read, update, delete). + """ + actions: [String!]! +} + +""" +Resource definition for role editor UI. +""" +type ResourceDefinition @join__type(graph: IAM_ADMIN) { + """ + Resource name (product, order, etc.). + """ + name: String! + """ + Available actions for resource. + """ + actions: [String!]! + """ + Display name. + """ + displayName: String + """ + Resource description. + """ + description: String +} + +type RoleCreatePayload @join__type(graph: IAM_ADMIN) { + role: Role + userErrors: [GenericUserError!]! +} + +type RoleUpdatePayload @join__type(graph: IAM_ADMIN) { + role: Role + userErrors: [GenericUserError!]! +} + +type RoleDeletePayload @join__type(graph: IAM_ADMIN) { + deletedRoleName: String + userErrors: [GenericUserError!]! +} + +type AuthorizePayload @join__type(graph: IAM_ADMIN) { + """ + Whether access is allowed. + """ + allowed: Boolean! + """ + Reason for denial (if denied). + """ + deniedReason: String +} + +""" +User session representing an active login. +""" +type Session @join__type(graph: IAM_ADMIN) { + """ + The globally unique ID of the session. + """ + id: ID! + """ + IP address from which the session was created. + """ + ipAddress: String + """ + User agent string (browser/device info). + """ + userAgent: String + """ + When the session expires. + """ + expiresAt: DateTime! + """ + Whether this is the current session making the request. + """ + isCurrent: Boolean! + """ + The date and time when the session was created. + """ + createdAt: DateTime! + """ + The date and time when the session was last updated. + """ + updatedAt: DateTime! +} + +""" +Payload for session revoke operation. +""" +type SessionRevokePayload @join__type(graph: IAM_ADMIN) { + """ + Whether the session was successfully revoked. + """ + success: Boolean! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for revoking all sessions. +""" +type SessionRevokeAllPayload @join__type(graph: IAM_ADMIN) { + """ + Number of sessions revoked. + """ + revokedCount: Int! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +User type representing admin users (CMS/backoffice). +""" +type User @join__type(graph: IAM_ADMIN, key: "id") @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) { + """ + The globally unique ID of the user. + """ + id: ID! + """ + User's email address. + """ + email: Email! @join__field(graph: IAM_ADMIN) + """ + Whether the email has been verified. + """ + emailVerified: Boolean @join__field(graph: IAM_ADMIN) + """ + User's first name. + """ + firstName: String @join__field(graph: IAM_ADMIN) + """ + User's last name. + """ + lastName: String @join__field(graph: IAM_ADMIN) + """ + User's avatar image (from Media service). + """ + avatar: File @join__field(graph: IAM_ADMIN) + """ + User's locale/language preference. + """ + locale: LocaleCode @join__field(graph: IAM_ADMIN) + """ + Whether the user has admin privileges. + """ + isAdmin: Boolean @join__field(graph: IAM_ADMIN) + """ + Whether the user account is forbidden/banned. + """ + isForbidden: Boolean @join__field(graph: IAM_ADMIN) + """ + Whether the user account is deleted. + """ + isDeleted: Boolean @join__field(graph: IAM_ADMIN) + """ + The date and time when the user was created. + """ + createdAt: DateTime @join__field(graph: IAM_ADMIN) + """ + The date and time when the user was last updated. + """ + updatedAt: DateTime @join__field(graph: IAM_ADMIN) + """ + Whether the user has completed their profile (firstName and lastName are filled). + Used for onboarding flow to ensure required fields are present. + """ + isProfileComplete: Boolean! @join__field(graph: IAM_ADMIN) +} + +""" +Payload for user profile update. +""" +type UserUpdateProfilePayload @join__type(graph: IAM_ADMIN) { + """ + The updated user. + """ + user: User + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for user email update. +""" +type UserUpdateEmailPayload @join__type(graph: IAM_ADMIN) { + """ + The updated user. + """ + user: User + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for admin user sign up. +""" +type UserSignUpPayload @join__type(graph: IAM_ADMIN) { + """ + The created user. + """ + user: User + """ + Authentication tokens. + """ + token: AuthTokenPayload + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for admin user sign in. +""" +type UserSignInPayload @join__type(graph: IAM_ADMIN) { + """ + The authenticated user. + """ + user: User + """ + Authentication tokens. + """ + token: AuthTokenPayload + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for admin user sign out. +""" +type UserSignOutPayload @join__type(graph: IAM_ADMIN) { + """ + Whether sign out was successful. + """ + success: Boolean! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for admin user token refresh. +""" +type UserTokenRefreshPayload @join__type(graph: IAM_ADMIN) { + """ + New authentication tokens. + """ + token: AuthTokenPayload + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for user password update. +""" +type UserUpdatePasswordPayload @join__type(graph: IAM_ADMIN) { + """ + Whether the password was changed successfully. + """ + success: Boolean! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +type ListingQuery @join__type(graph: LISTING_ADMIN) { + """ + Get a node by its global ID. + """ + node(id: ID!) : Node + """ + Get multiple nodes by their global IDs. + """ + nodes(ids: [ID!]!) : [Node]! + """ + Get ordered listing structure for Admin. + + Listing service returns listing-owned order, pagination, counts, aggregates, + and canonical entity references only. Entity details are resolved by owning + subgraphs through federation. + """ + listing( + first: Int + after: String + last: Int + before: String + scope: ListingScopeInput + query: String + locale: LocaleCode + currency: CurrencyCode + facets: [ListingProductFilter!] + orderBy: ListingOrderByInput + ): ListingConnection! + """ + Get a facet by ID. + """ + facet(id: ID!) : Facet + """ + Get all facets. + """ + facets: [Facet!]! + """ + Get available facet source candidates for create flow. + """ + facetSourceCandidates( + first: Int + after: String + last: Int + before: String + """ + Filter conditions. + """ + where: FacetSourceCandidateWhereInput + """ + Sort order. + """ + orderBy: [FacetSourceCandidateOrderByInput!] + ): FacetSourceCandidateConnection! + """ + Get available facet source value candidates for create and edit flows. + """ + facetValueCandidates( + first: Int + after: String + last: Int + before: String + """ + Filter conditions. + """ + where: FacetValueCandidateWhereInput + """ + Sort order. + """ + orderBy: [FacetValueCandidateOrderByInput!] + """ + Candidate routing and source scope. + """ + meta: FacetValueCandidatesMetaInput! + ): FacetValueCandidateConnection! + """ + Get a facet value by ID. + """ + facetValue(id: ID!) : FacetValue + """ + Get all facet values for a specific facet. + """ + facetValues(facetId: ID!) : [FacetValue!]! + """ + Get a facet swatch by ID. + """ + facetSwatch(id: ID!) : FacetSwatch + """ + Get all facet swatches. + """ + facetSwatches: [FacetSwatch!]! +} + +type ListingMutation @join__type(graph: LISTING_ADMIN) { + """ + Create a new facet. + """ + facetCreate(input: FacetCreateInput!) : FacetCreatePayload! + """ + Update an existing facet. + """ + facetUpdate(input: FacetUpdateInput!) : FacetUpdatePayload! + """ + Delete a facet. + """ + facetDelete(input: FacetDeleteInput!) : FacetDeletePayload! + """ + Move a facet before or after another facet. + """ + facetMove(input: FacetMoveInput!) : FacetMovePayload! + """ + Rebalance facet lexo ranks. + """ + facetRebalance(input: FacetRebalanceInput!) : FacetRebalancePayload! + """ + Create a new facet value. + """ + facetValueCreate(input: FacetValueCreateInput!) : FacetValueCreatePayload! + """ + Update an existing facet value. + """ + facetValueUpdate(input: FacetValueUpdateInput!) : FacetValueUpdatePayload! + """ + Attach source facet values to an existing or newly-created group value. + This is the only mutation that merges source values into a group value. + """ + facetValueMerge(input: FacetValueMergeInput!) : FacetValueMergePayload! + """ + Detach source facet values from their group value and make them root values. + This is the only mutation that unmerges source values. + """ + facetValueUnmerge(input: FacetValueUnmergeInput!) : FacetValueUnmergePayload! + """ + Delete a facet value. + """ + facetValueDelete(input: FacetValueDeleteInput!) : FacetValueDeletePayload! + """ + Create a new facet swatch. + """ + facetSwatchCreate(input: FacetSwatchCreateInput!) : FacetSwatchCreatePayload! + """ + Update an existing facet swatch. + """ + facetSwatchUpdate(input: FacetSwatchUpdateInput!) : FacetSwatchUpdatePayload! + """ + Delete a facet swatch. + """ + facetSwatchDelete(input: FacetSwatchDeleteInput!) : FacetSwatchDeletePayload! +} + +type Facet implements Node @join__type(graph: LISTING_ADMIN) @join__implements(graph: LISTING_ADMIN, interface: "Node") { + id: ID! + facetType: FacetType! + slug: String! + label: String! + uiType: FacetUIType! + selectionMode: FacetSelectionMode! + lexoRank: String! + sources: [FacetSource!]! + values: [FacetValue!]! +} + +type FacetSource @join__type(graph: LISTING_ADMIN) { + handle: String! + name: String! +} + +type FacetSourceCandidate @join__type(graph: LISTING_ADMIN) { + id: ID! + locale: String! + facetType: FacetType! + handle: String! + name: String +} + +type FacetSourceCandidateEdge @join__type(graph: LISTING_ADMIN) { + cursor: String! + node: FacetSourceCandidate! +} + +type FacetSourceCandidateConnection @join__type(graph: LISTING_ADMIN) { + edges: [FacetSourceCandidateEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type FacetValueCandidate @join__type(graph: LISTING_ADMIN) { + id: ID! + facetType: FacetType! + sourceHandle: String! + handle: String! + label: String! +} + +type FacetValueCandidateEdge @join__type(graph: LISTING_ADMIN) { + cursor: String! + node: FacetValueCandidate! +} + +type FacetValueCandidateConnection @join__type(graph: LISTING_ADMIN) { + edges: [FacetValueCandidateEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type FacetValue implements Node @join__type(graph: LISTING_ADMIN) @join__implements(graph: LISTING_ADMIN, interface: "Node") { + id: ID! + facet: Facet! + parent: FacetValue + kind: FacetValueKind! + handle: String! + label: String! + sourceValues: [FacetValue!]! + swatch: FacetSwatch + sortIndex: Int! + enabled: Boolean! +} + +type FacetSwatch implements Node @join__type(graph: LISTING_ADMIN, key: "id") @join__implements(graph: LISTING_ADMIN, interface: "Node") { + id: ID! + swatchType: SwatchType! + colorOne: String + colorTwo: String + file: File + metadata: JSON +} + +type FacetCreatePayload @join__type(graph: LISTING_ADMIN) { + facet: Facet + userErrors: [GenericUserError!]! +} + +type FacetUpdatePayload @join__type(graph: LISTING_ADMIN) { + facet: Facet + userErrors: [GenericUserError!]! +} + +type FacetDeletePayload @join__type(graph: LISTING_ADMIN) { + deletedFacetId: ID + userErrors: [GenericUserError!]! +} + +type FacetMovePayload @join__type(graph: LISTING_ADMIN) { + facet: Facet + userErrors: [GenericUserError!]! +} + +type FacetRebalancePayload @join__type(graph: LISTING_ADMIN) { + facets: [Facet!]! + userErrors: [GenericUserError!]! +} + +type FacetValueCreatePayload @join__type(graph: LISTING_ADMIN) { + facetValue: FacetValue + userErrors: [GenericUserError!]! +} + +type FacetValueUpdatePayload @join__type(graph: LISTING_ADMIN) { + facetValue: FacetValue + userErrors: [GenericUserError!]! +} + +type FacetValueMergePayload @join__type(graph: LISTING_ADMIN) { + facetValue: FacetValue + sourceValues: [FacetValue!]! + userErrors: [GenericUserError!]! +} + +type FacetValueUnmergePayload @join__type(graph: LISTING_ADMIN) { + sourceValues: [FacetValue!]! + affectedGroupValues: [FacetValue!]! + userErrors: [GenericUserError!]! +} + +type FacetValueDeletePayload @join__type(graph: LISTING_ADMIN) { + deletedFacetValueId: ID + userErrors: [GenericUserError!]! +} + +type FacetSwatchCreatePayload @join__type(graph: LISTING_ADMIN) { + facetSwatch: FacetSwatch + userErrors: [GenericUserError!]! +} + +type FacetSwatchUpdatePayload @join__type(graph: LISTING_ADMIN) { + facetSwatch: FacetSwatch + userErrors: [GenericUserError!]! +} + +type FacetSwatchDeletePayload @join__type(graph: LISTING_ADMIN) { + deletedFacetSwatchId: ID + userErrors: [GenericUserError!]! +} + +""" +A connection to a mixed list of catalog listing items. +""" +type ListingConnection @join__type(graph: LISTING_ADMIN) { + """ + A list of edges. + """ + edges: [ListingEdge!]! + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + The total number of matched sellable items. + """ + totalCount: Int! + """ + Ordered facet items available for the current listing result. + """ + facets: [ListingFacet!]! +} + +""" +An edge in a Listing connection. +""" +type ListingEdge @join__type(graph: LISTING_ADMIN) { + """ + The item at the end of the edge. + """ + node: Listing! + """ + A cursor for use in pagination. + """ + cursor: String! +} + +type ListingFacet @join__type(graph: LISTING_ADMIN) { + """ + Stable listing facet ID. + """ + id: String! + """ + Human-readable facet label. + """ + label: String! + """ + Facet presentation/selection type. + """ + type: ListingFacetType! + """ + Facet UI type. + """ + uiType: FacetUIType! + """ + Ordered values for this facet in listing UI order. + """ + values: [ListingFacetValue!]! +} + +type ListingFacetValue @join__type(graph: LISTING_ADMIN) { + """ + Stable listing facet value ID. + """ + id: String! + """ + Human-readable value label. + """ + label: String! + """ + Number of matched sellable items for this value. + """ + count: Int! + """ + Whether this value was selected in the current request. + """ + selected: Boolean! + """ + JSON object compatible with ListingProductFilter. + This keeps product, vendor, price and availability facets on one contract. + """ + input: JSON! + """ + Swatch metadata for facet values that have one. + """ + swatch: FacetSwatch +} + +type MediaQuery @join__type(graph: MEDIA_ADMIN) { + """ + Get a node by its global ID + """ + node(id: ID!) : Node + """ + Get multiple nodes by their global IDs + """ + nodes(ids: [ID!]!) : [Node]! + """ + Get a file by ID + """ + file(""" + File ID + """ + id: ID!) : File + """ + Get files with Relay-style pagination. + Store context is determined from x-store-name header. + """ + files( + first: Int + after: String + last: Int + before: String + """ + Filter conditions + """ + where: FileWhereInput + """ + Sort order + """ + orderBy: [FileOrderByInput!] + ): FileConnection! +} + +type MediaMutation @join__type(graph: MEDIA_ADMIN) { + bucketCreate(input: BucketCreateInput!) : BucketCreatePayload! + fileUpload(input: FileUploadMultipartInput!) : FileUploadPayload! + fileUploadFromUrl(input: FileUploadFromUrlInput!) : FileUploadPayload! + fileCreateExternal(input: FileCreateExternalInput!) : FileCreateExternalPayload! + fileUpdate(input: FileUpdateInput!) : FileUpdatePayload! + fileDelete(input: FileDeleteInput!) : FileDeletePayload! + """ + Upload avatar or logo for an entity (user profile or organization). + The file is stored in the entity's asset group. + """ + avatarUpload(input: AvatarUploadInput!) : AvatarUploadPayload! + """ + Delete multiple files by ID. + """ + fileDeleteMany(input: FileDeleteManyInput!) : FileDeleteManyPayload! + """ + Restore a single deleted file by ID. + """ + fileRestore(input: FileRestoreInput!) : FileRestorePayload! + """ + Restore multiple deleted files by ID. + """ + fileRestoreMany(input: FileRestoreManyInput!) : FileRestoreManyPayload! + """ + Clear errors for multiple files by ID. + """ + fileClearError(input: FileClearErrorInput!) : FileClearErrorPayload! +} + +""" +A bucket represents an S3 storage bucket for a project. +""" +type Bucket @join__type(graph: MEDIA_ADMIN) { + """ + The globally unique ID of the bucket. + """ + id: ID! + """ + S3 bucket name. + """ + bucketName: String! + """ + AWS region. + """ + region: String! + """ + Bucket status (active, archived, etc). + """ + status: String! + """ + Priority for bucket selection. + """ + priority: Int! + """ + Custom endpoint URL (for S3-compatible storage). + """ + endpointUrl: String + """ + The date and time when the bucket was created. + """ + createdAt: DateTime! + """ + The date and time when the bucket was last updated. + """ + updatedAt: DateTime! +} + +""" +Payload for bucket creation. +""" +type BucketCreatePayload @join__type(graph: MEDIA_ADMIN) { + """ + The created bucket. + """ + bucket: Bucket + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +A connection to a list of File items. +""" +type FileConnection @join__type(graph: MEDIA_ADMIN) { + """ + A list of edges. + """ + edges: [FileEdge!]! + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + The total number of files. + """ + totalCount: Int! +} + +""" +An edge in a File connection. +""" +type FileEdge @join__type(graph: MEDIA_ADMIN) { + """ + The item at the end of the edge. + """ + node: File! + """ + A cursor for use in pagination. + """ + cursor: String! +} + +""" +Image/video dimensions. +""" +type MediaDimensions @join__type(graph: MEDIA_ADMIN) { + """ + Width in pixels. + """ + width: Int! + """ + Height in pixels. + """ + height: Int! +} + +""" +S3-specific file data. +""" +type S3ObjectData @join__type(graph: MEDIA_ADMIN) { + """ + The bucket ID where this file is stored. + """ + bucketId: ID! + """ + S3 object key (path within bucket). + """ + objectKey: String! + """ + ETag from S3. + """ + etag: String + """ + Storage class (STANDARD, GLACIER, etc). + """ + storageClass: String! +} + +""" +External media data (YouTube, Vimeo, etc). +""" +type ExternalMediaData @join__type(graph: MEDIA_ADMIN) { + """ + External ID (YouTube video ID, Vimeo ID, etc). + """ + externalId: String! + """ + Provider-specific metadata. + """ + providerMeta: JSON +} + +type FileUsageCount @join__type(graph: MEDIA_ADMIN) { + """ + Entity type (variant, user, organization, etc). + """ + entityType: String! + """ + Number of unique entities referencing the file. + """ + count: Int! +} + +type FileUsageSummary @join__type(graph: MEDIA_ADMIN) { + """ + Total number of unique entities referencing the file. + """ + totalCount: Int! + """ + Usage breakdown by entity type. + """ + byEntity: [FileUsageCount!]! + """ + Whether the file is active (not soft-deleted). + """ + fileActive: Boolean! +} + +""" +Payload for file upload. +""" +type FileUploadPayload @join__type(graph: MEDIA_ADMIN) { + """ + The uploaded file. + """ + file: File + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for external file creation. +""" +type FileCreateExternalPayload @join__type(graph: MEDIA_ADMIN) { + """ + The created file. + """ + file: File + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for file update. +""" +type FileUpdatePayload @join__type(graph: MEDIA_ADMIN) { + """ + The updated file. + """ + file: File + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for file deletion. +""" +type FileDeletePayload @join__type(graph: MEDIA_ADMIN) { + """ + The ID of the deleted file. + """ + deletedFileId: ID + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +type FileDeleteManyPayload @join__type(graph: MEDIA_ADMIN) { + """ + Files that were eligible and transitioned to SOFT_DELETED. + """ + acceptedIds: [ID!]! + """ + Files for which hard delete workflow was started. + """ + startedHardDeleteIds: [ID!]! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +type FileRestorePayload @join__type(graph: MEDIA_ADMIN) { + """ + The restored file. + """ + file: File + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +type FileRestoreManyPayload @join__type(graph: MEDIA_ADMIN) { + """ + Files that were successfully restored. + """ + restoredIds: [ID!]! + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +type FileClearErrorPayload @join__type(graph: MEDIA_ADMIN) { + """ + The file with cleared deletion error. + """ + file: File + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +""" +Payload for avatar/logo upload. +""" +type AvatarUploadPayload @join__type(graph: MEDIA_ADMIN) { + """ + The uploaded file. + """ + file: File + """ + List of errors that occurred during the mutation. + """ + userErrors: [GenericUserError!]! +} + +type OrderCustomerIdentity @join__type(graph: ORDERS_ADMIN) { + customer: Customer + email: Email + phone: String + countryCode: CountryCode + data: JSON +} + +type Order @join__type(graph: ORDERS_ADMIN) { + id: ID! + createdBy: OrderActor! + status: OrderStatus! + number: BigInt! + currencyCode: String! + subtotal: BigInt! + shippingTotal: BigInt + discountTotal: BigInt + taxTotal: BigInt + grandTotal: BigInt! + customerIdentity: OrderCustomerIdentity! + customerStatistic: OrderCustomerStatistic! + adminNote: String + customerNote: String + events: [OrderEvent!]! + tags: [Tag!]! + labels: [Label!]! + lines: [OrderLine!]! + createdAt: DateTime! + updatedAt: DateTime! + deletedAt: DateTime +} + +type OrderLine @join__type(graph: ORDERS_ADMIN) { + id: ID! + quantity: Int! + unitPrice: Int! + unitComparePrice: Int! + discountAmount: Int! + subtotalAmount: Int! + taxAmount: Int + totalAmount: Int! + purchasableId: ID! + createdAt: DateTime! + updatedAt: DateTime! +} + +type OrderDeliveryAddress @join__type(graph: ORDERS_ADMIN) { + id: ID! + address1: String! + address2: String + city: String! + countryCode: CountryCode! + provinceCode: String + postalCode: String + email: Email + firstName: String + lastName: String + data: JSON +} + +type OrderCustomerStatistic @join__type(graph: ORDERS_ADMIN) { + totalRevenue: Int! + totalGuestOrders: Int! + totalAuthorizedOrders: Int! +} + +type OrderEvent @join__type(graph: ORDERS_ADMIN) { + id: String! + performedBy: OrderActor! + eventType: OrderEventType! + data: JSON + metadata: JSON + createdAt: DateTime! +} + +type OrdersOutput @join__type(graph: ORDERS_ADMIN) { + data: [Order!]! + meta: CollectionMeta! +} + +type OrderQuery @join__type(graph: ORDERS_ADMIN) { + orders(input: OrdersInput) : OrdersOutput! + order(id: ID!) : Order +} + +type OrderMutation @join__type(graph: ORDERS_ADMIN) { + orderAdminNoteUpdate(input: OrderAdminNoteUpdateInput!) : Boolean! + orderCommentAdd(input: OrderCommentAddInput!) : Boolean! + orderCancel(input: OrderCancelInput!) : Boolean! + orderClose(input: OrderCloseInput!) : Boolean! +} + +type Weight @join__type(graph: ORDERS_ADMIN) { + weight: Float! + unit: WeightUnit! +} + +type CollectionMeta @join__type(graph: ORDERS_ADMIN) { + page: Int! + pageSize: Int! + count: Int! + total: Int! + pageCount: Int! +} + +type PurchasableSnapshot implements Purchasable @join__type(graph: ORDERS_ADMIN) @join__implements(graph: ORDERS_ADMIN, interface: "Purchasable") { + id: ID! + purchasableSnapshot: JSON! +} + +""" +API key for programmatic access to the project +""" +type ApiKey @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) @join__type(graph: PROJECT_ADMIN, key: "id") { + """ + Unique identifier of the API key + """ + id: ID! + """ + Human-readable name for the API key + """ + name: String! @join__field(graph: PROJECT_ADMIN) + """ + The API key value (only shown once upon creation) + """ + key: String! @join__field(graph: PROJECT_ADMIN) + """ + Timestamp when the API key was created + """ + createdAt: DateTime! @join__field(graph: PROJECT_ADMIN) + """ + ID of the user who created this API key + """ + createdById: ID! @join__field(graph: PROJECT_ADMIN) + """ + Optional expiration date for the API key + """ + dueDate: DateTime @join__field(graph: PROJECT_ADMIN) + """ + Timestamp of the last API call using this key + """ + lastUsedAt: DateTime @join__field(graph: PROJECT_ADMIN) + """ + Whether the API key has been banned by the system + """ + isBanned: Boolean! @join__field(graph: PROJECT_ADMIN) + """ + Timestamp when the API key was revoked, null if still active + """ + revokedAt: DateTime @join__field(graph: PROJECT_ADMIN) +} + +type Customer @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) { + id: ID! +} + +type Label @join__type(graph: ORDERS_ADMIN, key: "id", extension: true) { + id: ID! +} + +""" +Payload returned after creating an API key +""" +type ApiKeyCreatePayload @join__type(graph: PROJECT_ADMIN) { + """ + The newly created API key, null if creation failed + """ + apiKey: ApiKey + """ + List of errors that occurred during creation + """ + userErrors: [UserError!]! +} + +""" +Payload returned after an API key action (revoke) +""" +type ApiKeyActionPayload @join__type(graph: PROJECT_ADMIN) { + """ + Whether the action was successful + """ + success: Boolean! + """ + List of errors that occurred during the action + """ + userErrors: [UserError!]! +} + +""" +Payload returned after deleting an API key +""" +type ApiKeyDeletePayload @join__type(graph: PROJECT_ADMIN) { + """ + ID of the deleted API key, null if deletion failed + """ + deletedApiKeyId: ID + """ + List of errors that occurred during deletion + """ + userErrors: [UserError!]! +} + +""" +Queries for store management +""" +type StoreQuery @join__type(graph: PROJECT_ADMIN) { + """ + Get all stores accessible to the current user in the organization + """ + stores(""" + Organization ID to list stores from + """ + organizationId: ID!) : [Store!]! + """ + Get the current store from context + """ + currentStore: Store + """ + Get all API keys for the current store + """ + apiKeys: [ApiKey!]! +} + +""" +Mutations for store management +""" +type StoreMutation @join__type(graph: PROJECT_ADMIN) { + """ + Create a new store + """ + storeCreate(input: StoreCreateInput!) : StoreCreatePayload! + """ + Update an existing store + """ + storeUpdate(input: StoreUpdateInput!) : StoreUpdatePayload! + """ + Delete a store + """ + storeDelete(input: StoreDeleteInput!) : StoreDeletePayload! + """ + Add a new currency to the store + """ + currencyCreate(input: CurrencyCreateInput!) : CurrencyCreatePayload! + """ + Remove a currency from the store + """ + currencyDelete(input: CurrencyDeleteInput!) : CurrencyDeletePayload! + """ + Set the default currency for the store + """ + currencySetDefault(input: CurrencySetDefaultInput!) : CurrencyUpdatePayload! + """ + Add a new locale to the store + """ + localeCreate(input: LocaleCreateInput!) : LocaleCreatePayload! + """ + Remove a locale from the store + """ + localeDelete(input: LocaleDeleteInput!) : LocaleDeletePayload! + """ + Set the default locale for the store + """ + localeSetDefault(input: LocaleSetDefaultInput!) : LocaleUpdatePayload! + """ + Create a new API key for programmatic access + """ + apiKeyCreate(input: ApiKeyCreateInput!) : ApiKeyCreatePayload! + """ + Revoke an API key (soft delete) + """ + apiKeyRevoke(input: ApiKeyRevokeInput!) : ApiKeyActionPayload! + """ + Permanently delete an API key + """ + apiKeyDelete(input: ApiKeyDeleteInput!) : ApiKeyDeletePayload! +} + +""" +Currency configuration for the project +""" +type Currency @join__type(graph: PROJECT_ADMIN) { + """ + ISO 4217 currency code + """ + code: CurrencyCode! + """ + Display name of the currency + """ + name: String! + """ + Whether this currency is currently active for the project + """ + isActive: Boolean! + """ + Exchange rate relative to the base currency + """ + exchangeRate: ExchangeRate! +} + +""" +Exchange rate representation using integer arithmetic for precision +""" +type ExchangeRate @join__type(graph: PROJECT_ADMIN) { + """ + The exchange rate value as an integer (divide by 10^scale for actual rate) + """ + amount: Int! + """ + The number of decimal places in the amount + """ + scale: Int! +} + +""" +Payload returned after creating a currency +""" +type CurrencyCreatePayload @join__type(graph: PROJECT_ADMIN) { + """ + The newly created currency, null if creation failed + """ + currency: Currency + """ + List of errors that occurred during creation + """ + userErrors: [UserError!]! +} + +""" +Payload returned after deleting a currency +""" +type CurrencyDeletePayload @join__type(graph: PROJECT_ADMIN) { + """ + The code of the deleted currency, null if deletion failed + """ + deletedCurrencyCode: CurrencyCode + """ + List of errors that occurred during deletion + """ + userErrors: [UserError!]! +} + +""" +Payload returned after updating currency settings +""" +type CurrencyUpdatePayload @join__type(graph: PROJECT_ADMIN) { + """ + Whether the update was successful + """ + success: Boolean! + """ + List of errors that occurred during update + """ + userErrors: [UserError!]! +} + +""" +Locale configuration for the project +""" +type Locale @join__type(graph: PROJECT_ADMIN) { + """ + BCP 47 locale code + """ + code: LocaleCode! + """ + Display name of the locale + """ + name: String! + """ + Whether this locale is currently active for the project + """ + isActive: Boolean! +} + +""" +Payload returned after creating a locale +""" +type LocaleCreatePayload @join__type(graph: PROJECT_ADMIN) { + """ + The newly created locale, null if creation failed + """ + locale: Locale + """ + List of errors that occurred during creation + """ + userErrors: [UserError!]! +} + +""" +Payload returned after deleting a locale +""" +type LocaleDeletePayload @join__type(graph: PROJECT_ADMIN) { + """ + The code of the deleted locale, null if deletion failed + """ + deletedLocaleCode: LocaleCode + """ + List of errors that occurred during deletion + """ + userErrors: [UserError!]! +} + +""" +Payload returned after updating locale settings +""" +type LocaleUpdatePayload @join__type(graph: PROJECT_ADMIN) { + """ + Whether the update was successful + """ + success: Boolean! + """ + List of errors that occurred during update + """ + userErrors: [UserError!]! +} + +""" +A store +""" +type Store @join__type(graph: PROJECT_ADMIN, key: "id") { + """ + Unique identifier of the store + """ + id: ID! + """ + Organization that owns this store (federation reference) + """ + organization: Organization + """ + URL-friendly unique identifier + """ + name: String! + """ + Display name of the store + """ + displayName: String! + """ + Current operational status of the store + """ + status: StoreStatus! + """ + IANA timezone identifier for the store + """ + timezone: String! + """ + Contact email address for the store + """ + email: String + """ + List of enabled locale codes for the store + """ + locales: [LocaleCode!]! + """ + List of enabled currency codes for the store + """ + currencies: [CurrencyCode!]! + """ + Base currency used for exchange rate calculations + """ + baseCurrency: CurrencyCode! + """ + Default locale for new content + """ + defaultLocale: LocaleCode! + """ + Default currency for pricing display + """ + defaultCurrency: CurrencyCode! + """ + Default unit for product weights + """ + defaultWeightUnit: WeightUnit! + """ + Default unit for product dimensions + """ + defaultDimensionUnit: DimensionUnit! + """ + Timestamp when the store was created + """ + createdAt: DateTime! + """ + Timestamp when the store was last updated + """ + updatedAt: DateTime! + """ + Membership info (resolved from IAM by domain) + """ + membership: Membership! +} + +""" +Payload returned after creating a store +""" +type StoreCreatePayload @join__type(graph: PROJECT_ADMIN) { + """ + The newly created store, null if creation failed + """ + store: Store + """ + List of errors that occurred during creation + """ + userErrors: [UserError!]! +} + +""" +Payload returned after updating a store +""" +type StoreUpdatePayload @join__type(graph: PROJECT_ADMIN) { + """ + The updated store, null if update failed + """ + store: Store + """ + List of errors that occurred during update + """ + userErrors: [UserError!]! +} + +""" +Payload returned after deleting a store +""" +type StoreDeletePayload @join__type(graph: PROJECT_ADMIN) { + """ + ID of the deleted store, null if deletion failed + """ + deletedStoreId: ID + """ + List of errors that occurred during deletion + """ + userErrors: [UserError!]! +} + +""" +A generic user error interface for mutation responses. +""" +interface UserError @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: PROJECT_ADMIN) { + """ + The error message. + """ + message: String! + """ + The path to the input field that caused the error. + """ + field: [String!] + """ + An error code for programmatic handling. + """ + code: String +} + +""" +The Node interface is implemented by all types that have a globally unique ID. +""" +interface Node @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) { + """ + The globally unique ID of the object. + """ + id: ID! +} + +interface BundlePriceRule implements Node @join__type(graph: CATALOG_ADMIN) @join__implements(graph: CATALOG_ADMIN, interface: "Node") { + """ + The globally unique ID of the price rule. + """ + id: ID! + """ + Pricing strategy. + """ + priceType: BundlePriceType! +} + +interface Listing implements Node @join__type(graph: LISTING_ADMIN) @join__implements(graph: LISTING_ADMIN, interface: "Node") { + """ + The global ID of the catalog listing item. + """ + id: ID! +} + +interface Purchasable @join__type(graph: ORDERS_ADMIN) { + """ + Unique identifier of the purchasable entity. + """ + id: ID! +} + +union OrderActor @join__type(graph: ORDERS_ADMIN) @join__unionMember(graph: ORDERS_ADMIN, member: "User") @join__unionMember(graph: ORDERS_ADMIN, member: "ApiKey") = User | ApiKey + +""" +Currency codes according to ISO 4217 +""" +enum CurrencyCode @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { + """ + UAE Dirham (United Arab Emirates) - 2 decimals + """ + AED @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Afghan Afghani (Afghanistan) - 0 decimals + """ + AFN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Albanian Lek (Albania) - 0 decimals + """ + ALL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Armenian Dram (Armenia) - 2 decimals + """ + AMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Netherlands Antillean Guilder - 2 decimals + """ + ANG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Angolan Kwanza (Angola) - 2 decimals + """ + AOA @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Argentine Peso (Argentina) - 2 decimals + """ + ARS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Australian Dollar (Australia) - 2 decimals + """ + AUD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Aruban Florin (Aruba) - 2 decimals + """ + AWG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Azerbaijani Manat (Azerbaijan) - 2 decimals + """ + AZN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bosnia-Herzegovina Convertible Mark - 2 decimals + """ + BAM @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Barbadian Dollar (Barbados) - 2 decimals + """ + BBD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bangladeshi Taka (Bangladesh) - 2 decimals + """ + BDT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bulgarian Lev (Bulgaria) - 2 decimals + """ + BGN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bahraini Dinar (Bahrain) - 3 decimals + """ + BHD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Burundian Franc (Burundi) - 0 decimals + """ + BIF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bermudian Dollar (Bermuda) - 2 decimals + """ + BMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Brunei Dollar (Brunei) - 2 decimals + """ + BND @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bolivian Boliviano (Bolivia) - 2 decimals + """ + BOB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Brazilian Real (Brazil) - 2 decimals + """ + BRL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bahamian Dollar (Bahamas) - 2 decimals + """ + BSD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bhutanese Ngultrum (Bhutan) - 2 decimals + """ + BTN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Botswana Pula (Botswana) - 2 decimals + """ + BWP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Belarusian Ruble (Belarus) - 2 decimals + """ + BYN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Belize Dollar (Belize) - 2 decimals + """ + BZD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Canadian Dollar (Canada) - 2 decimals + """ + CAD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Congolese Franc (DR Congo) - 2 decimals + """ + CDF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Swiss Franc (Switzerland) - 2 decimals + """ + CHF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Chilean Peso (Chile) - 0 decimals + """ + CLP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Chinese Yuan (China) - 2 decimals + """ + CNY @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Colombian Peso (Colombia) - 2 decimals + """ + COP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Costa Rican Colon (Costa Rica) - 2 decimals + """ + CRC @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Cuban Peso (Cuba) - 2 decimals + """ + CUP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Cape Verdean Escudo (Cape Verde) - 2 decimals + """ + CVE @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Czech Koruna (Czech Republic) - 2 decimals + """ + CZK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Djiboutian Franc (Djibouti) - 0 decimals + """ + DJF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Danish Krone (Denmark) - 2 decimals + """ + DKK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Dominican Peso (Dominican Republic) - 2 decimals + """ + DOP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Algerian Dinar (Algeria) - 2 decimals + """ + DZD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Egyptian Pound (Egypt) - 2 decimals + """ + EGP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Eritrean Nakfa (Eritrea) - 2 decimals + """ + ERN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ethiopian Birr (Ethiopia) - 2 decimals + """ + ETB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Euro (European Union) - 2 decimals + """ + EUR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Fijian Dollar (Fiji) - 2 decimals + """ + FJD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Falkland Islands Pound - 2 decimals + """ + FKP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Faroese Króna (Faroe Islands) - 2 decimals + """ + FOK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Pound Sterling (United Kingdom) - 2 decimals + """ + GBP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Georgian Lari (Georgia) - 2 decimals + """ + GEL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Guernsey Pound (Guernsey) - 2 decimals + """ + GGP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ghanaian Cedi (Ghana) - 2 decimals + """ + GHS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Gibraltar Pound (Gibraltar) - 2 decimals + """ + GIP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Gambian Dalasi (Gambia) - 2 decimals + """ + GMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Guinean Franc (Guinea) - 0 decimals + """ + GNF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Guatemalan Quetzal (Guatemala) - 2 decimals + """ + GTQ @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Guyanese Dollar (Guyana) - 2 decimals + """ + GYD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Hong Kong Dollar (Hong Kong) - 2 decimals + """ + HKD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Honduran Lempira (Honduras) - 2 decimals + """ + HNL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Croatian Kuna (Croatia) - 2 decimals + """ + HRK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Haitian Gourde (Haiti) - 2 decimals + """ + HTG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Hungarian Forint (Hungary) - 2 decimals + """ + HUF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Indonesian Rupiah (Indonesia) - 0 decimals + """ + IDR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Israeli New Shekel (Israel) - 2 decimals + """ + ILS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Isle of Man Pound - 2 decimals + """ + IMP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Indian Rupee (India) - 2 decimals + """ + INR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Iraqi Dinar (Iraq) - 3 decimals + """ + IQD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Iranian Rial (Iran) - 2 decimals + """ + IRR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Icelandic Króna (Iceland) - 0 decimals + """ + ISK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Jersey Pound (Jersey) - 2 decimals + """ + JEP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Jamaican Dollar (Jamaica) - 2 decimals + """ + JMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Jordanian Dinar (Jordan) - 3 decimals + """ + JOD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Japanese Yen (Japan) - 0 decimals + """ + JPY @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kenyan Shilling (Kenya) - 2 decimals + """ + KES @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kyrgyzstani Som (Kyrgyzstan) - 2 decimals + """ + KGS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Cambodian Riel (Cambodia) - 2 decimals + """ + KHR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Comorian Franc (Comoros) - 2 decimals + """ + KMF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + North Korean Won (North Korea) - 2 decimals + """ + KPW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + South Korean Won (South Korea) - 0 decimals + """ + KRW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kuwaiti Dinar (Kuwait) - 3 decimals + """ + KWD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Cayman Islands Dollar - 2 decimals + """ + KYD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kazakhstani Tenge (Kazakhstan) - 2 decimals + """ + KZT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Lao Kip (Laos) - 2 decimals + """ + LAK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Lebanese Pound (Lebanon) - 2 decimals + """ + LBP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sri Lankan Rupee (Sri Lanka) - 2 decimals + """ + LKR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Liberian Dollar (Liberia) - 2 decimals + """ + LRD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Lesotho Loti (Lesotho) - 2 decimals + """ + LSL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Libyan Dinar (Libya) - 3 decimals + """ + LYD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Moroccan Dirham (Morocco) - 2 decimals + """ + MAD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Moldovan Leu (Moldova) - 2 decimals + """ + MDL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Malagasy Ariary (Madagascar) - 2 decimals + """ + MGA @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Macedonian Denar (North Macedonia) - 2 decimals + """ + MKD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Burmese Kyat (Myanmar) - 2 decimals + """ + MMK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Mongolian Tögrög (Mongolia) - 2 decimals + """ + MNT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Macanese Pataca (Macau) - 2 decimals + """ + MOP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Mauritanian Ouguiya (Mauritania) - 2 decimals + """ + MRU @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Mauritian Rupee (Mauritius) - 2 decimals + """ + MUR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Maldivian Rufiyaa (Maldives) - 2 decimals + """ + MVR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Malawian Kwacha (Malawi) - 2 decimals + """ + MWK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Mexican Peso (Mexico) - 2 decimals + """ + MXN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Malaysian Ringgit (Malaysia) - 2 decimals + """ + MYR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Mozambican Metical (Mozambique) - 2 decimals + """ + MZN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Namibian Dollar (Namibia) - 2 decimals + """ + NAD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Nigerian Naira (Nigeria) - 2 decimals + """ + NGN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Nicaraguan Córdoba (Nicaragua) - 2 decimals + """ + NIO @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Norwegian Krone (Norway) - 2 decimals + """ + NOK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Nepalese Rupee (Nepal) - 2 decimals + """ + NPR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + New Zealand Dollar (New Zealand) - 2 decimals + """ + NZD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Omani Rial (Oman) - 3 decimals + """ + OMR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Panamanian Balboa (Panama) - 2 decimals + """ + PAB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Peruvian Sol (Peru) - 2 decimals + """ + PEN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Papua New Guinean Kina - 2 decimals + """ + PGK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Philippine Peso (Philippines) - 2 decimals + """ + PHP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Pakistani Rupee (Pakistan) - 2 decimals + """ + PKR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Polish Zloty (Poland) - 2 decimals + """ + PLN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Paraguayan Guaraní (Paraguay) - 0 decimals + """ + PYG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Qatari Riyal (Qatar) - 2 decimals + """ + QAR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Romanian Leu (Romania) - 2 decimals + """ + RON @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Serbian Dinar (Serbia) - 2 decimals + """ + RSD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Russian Ruble (Russia) - 2 decimals + """ + RUB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Rwandan Franc (Rwanda) - 0 decimals + """ + RWF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Saudi Riyal (Saudi Arabia) - 2 decimals + """ + SAR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Solomon Islands Dollar - 2 decimals + """ + SBD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Seychelles Rupee (Seychelles) - 2 decimals + """ + SCR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sudanese Pound (Sudan) - 2 decimals + """ + SDG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Swedish Krona (Sweden) - 2 decimals + """ + SEK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Singapore Dollar (Singapore) - 2 decimals + """ + SGD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Saint Helena Pound - 2 decimals + """ + SHP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sierra Leonean Leone - 2 decimals + """ + SLE @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Somali Shilling (Somalia) - 2 decimals + """ + SOS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Surinamese Dollar (Suriname) - 2 decimals + """ + SRD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + South Sudanese Pound - 2 decimals + """ + SSP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + São Tomé and Príncipe Dobra - 2 decimals + """ + STN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Salvadoran Colón (El Salvador) - 2 decimals + """ + SVC @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Syrian Pound (Syria) - 2 decimals + """ + SYP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Eswatini Lilangeni (Eswatini) - 2 decimals + """ + SZL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Thai Baht (Thailand) - 2 decimals + """ + THB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tajikistani Somoni (Tajikistan) - 2 decimals + """ + TJS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Turkmenistani Manat (Turkmenistan) - 2 decimals + """ + TMT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tunisian Dinar (Tunisia) - 3 decimals + """ + TND @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tongan Paʻanga (Tonga) - 2 decimals + """ + TOP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Turkish Lira (Turkey) - 2 decimals + """ + TRY @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Trinidad and Tobago Dollar - 2 decimals + """ + TTD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + New Taiwan Dollar (Taiwan) - 2 decimals + """ + TWD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tanzanian Shilling (Tanzania) - 2 decimals + """ + TZS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ukrainian Hryvnia (Ukraine) - 2 decimals + """ + UAH @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ugandan Shilling (Uganda) - 0 decimals + """ + UGX @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + United States Dollar (USA) - 2 decimals + """ + USD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Uruguayan Peso (Uruguay) - 2 decimals + """ + UYU @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Uzbekistani Som (Uzbekistan) - 2 decimals + """ + UZS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Venezuelan Bolívar (Venezuela) - 2 decimals + """ + VES @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Vietnamese Dong (Vietnam) - 0 decimals + """ + VND @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Vanuatu Vatu (Vanuatu) - 0 decimals + """ + VUV @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Samoan Tala (Samoa) - 2 decimals + """ + WST @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Central African CFA Franc - 0 decimals + """ + XAF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + East Caribbean Dollar - 2 decimals + """ + XCD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Special Drawing Rights (IMF) - 0 decimals + """ + XDR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + West African CFA Franc - 0 decimals + """ + XOF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + CFP Franc - 0 decimals + """ + XPF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Yemeni Rial (Yemen) - 2 decimals + """ + YER @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + South African Rand (South Africa) - 2 decimals + """ + ZAR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Zambian Kwacha (Zambia) - 2 decimals + """ + ZMW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Zimbabwean Dollar (Zimbabwe) - 2 decimals + """ + ZWL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) +} + +""" +Language/Locale codes based on ISO 639-1 and BCP 47 +""" +enum LocaleCode @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { + """ + Akan + """ + ak @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Albanian + """ + sq @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Amharic + """ + am @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Arabic + """ + ar @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Armenian + """ + hy @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Assamese + """ + as @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Azerbaijani + """ + az @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bambara + """ + bm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bangla + """ + bn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Basque + """ + eu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Belarusian + """ + be @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bosnian + """ + bs @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Breton + """ + br @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Bulgarian + """ + bg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Burmese + """ + my @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Catalan + """ + ca @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Central Kurdish + """ + ckb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Chechen + """ + ce @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Chinese (Simplified) + """ + zh_CN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Chinese (Traditional) + """ + zh_TW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Cornish + """ + kw @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Croatian + """ + hr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Czech + """ + cs @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Danish + """ + da @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Dutch + """ + nl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Dzongkha + """ + dz @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + English + """ + en @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Esperanto + """ + eo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Estonian + """ + et @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ewe + """ + ee @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Faroese + """ + fo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Filipino + """ + fil @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Finnish + """ + fi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + French + """ + fr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Fulah + """ + ff @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Galician + """ + gl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ganda + """ + lg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Georgian + """ + ka @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + German + """ + de @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Greek + """ + el @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Gujarati + """ + gu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Hausa + """ + ha @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Hebrew + """ + he @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Hindi + """ + hi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Hungarian + """ + hu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Icelandic + """ + is @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Igbo + """ + ig @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Indonesian + """ + id @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Interlingua + """ + ia @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Irish + """ + ga @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Italian + """ + it @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Japanese + """ + ja @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Javanese + """ + jv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kalaallisut + """ + kl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kannada + """ + kn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kashmiri + """ + ks @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kazakh + """ + kk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Khmer + """ + km @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kikuyu + """ + ki @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kinyarwanda + """ + rw @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Korean + """ + ko @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kurdish + """ + ku @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Kyrgyz + """ + ky @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Lao + """ + lo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Latvian + """ + lv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Lingala + """ + ln @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Lithuanian + """ + lt @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Luba-Katanga + """ + lu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Luxembourgish + """ + lb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Macedonian + """ + mk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Malagasy + """ + mg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Malay + """ + ms @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Malayalam + """ + ml @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Maltese + """ + mt @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Manx + """ + gv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Marathi + """ + mr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Mongolian + """ + mn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Māori + """ + mi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Nepali + """ + ne @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + North Ndebele + """ + nd @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Northern Sami + """ + se @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Norwegian + """ + no @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Norwegian Bokmål + """ + nb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Norwegian Nynorsk + """ + nn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Odia + """ + or @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Oromo + """ + om @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ossetic + """ + os @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Pashto + """ + ps @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Persian + """ + fa @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Polish + """ + pl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Portuguese (Brazil) + """ + pt_BR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Portuguese (Portugal) + """ + pt_PT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Punjabi + """ + pa @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Quechua + """ + qu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Romanian + """ + ro @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Romansh + """ + rm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Rundi + """ + rn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Russian + """ + ru @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sango + """ + sg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sanskrit + """ + sa @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sardinian + """ + sc @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Scottish Gaelic + """ + gd @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Serbian + """ + sr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Shona + """ + sn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sichuan Yi + """ + ii @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sindhi + """ + sd @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sinhala + """ + si @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Slovak + """ + sk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Slovenian + """ + sl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Somali + """ + so @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Spanish + """ + es @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Sundanese + """ + su @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Swahili + """ + sw @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Swedish + """ + sv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tajik + """ + tg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tamil + """ + ta @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tatar + """ + tt @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Telugu + """ + te @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Thai + """ + th @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tibetan + """ + bo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tigrinya + """ + ti @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Tongan + """ + to @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Turkish + """ + tr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Turkmen + """ + tk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ukrainian + """ + uk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Urdu + """ + ur @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Uyghur + """ + ug @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Uzbek + """ + uz @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Vietnamese + """ + vi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Welsh + """ + cy @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Western Frisian + """ + fy @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Wolof + """ + wo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Xhosa + """ + xh @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Yiddish + """ + yi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Yoruba + """ + yo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Zulu + """ + zu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) +} + +""" +Weight measurement units +""" +enum WeightUnit @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { + """ + Kilogram + """ + kg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Gram + """ + g @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Pound + """ + lb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Ounce + """ + oz @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) +} + +""" +Dimension (length) measurement units +""" +enum DimensionUnit @join__type(graph: APPS_ADMIN) @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { + """ + Millimeter + """ + mm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Centimeter + """ + cm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Meter + """ + m @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Inch + """ + in @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + """ + Foot + """ + ft @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) +} + +""" +Sort direction +""" +enum SortDirection @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) { + asc @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) + desc @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: LISTING_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) +} + +""" +Fields available for sorting Product +""" +enum ProductOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by vendorId + """ + vendorId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by handle + """ + handle @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by publishedAt + """ + publishedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by locale + """ + locale @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by name + """ + name @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by currency + """ + currency @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by minAmountMinor + """ + minAmountMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by maxAmountMinor + """ + maxAmountMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by minPriceMinor + """ + minPriceMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by maxPriceMinor + """ + maxPriceMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by primaryCategoryId + """ + primaryCategoryId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by primaryCategoryName + """ + primaryCategoryName @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by brandName + """ + brandName @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting Bundle +""" +enum BundleOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by vendorId + """ + vendorId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by handle + """ + handle @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by publishedAt + """ + publishedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by locale + """ + locale @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by name + """ + name @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by currency + """ + currency @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by minAmountMinor + """ + minAmountMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by maxAmountMinor + """ + maxAmountMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by minPriceMinor + """ + minPriceMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by maxPriceMinor + """ + maxPriceMinor @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by primaryCategoryId + """ + primaryCategoryId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by primaryCategoryName + """ + primaryCategoryName @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by brandName + """ + brandName @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by bundleType + """ + bundleType @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting Vendor +""" +enum VendorOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by name + """ + name @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting Category +""" +enum CategoryOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by parentId + """ + parentId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by path + """ + path @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by depth + """ + depth @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by handle + """ + handle @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by defaultSort + """ + defaultSort @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by defaultSortDirection + """ + defaultSortDirection @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by publishedAt + """ + publishedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by productsCount + """ + productsCount @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by locale + """ + locale @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by name + """ + name @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting Tag +""" +enum TagOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by storeId + """ + storeId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by handle + """ + handle @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by productsCount + """ + productsCount @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by locale + """ + locale @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by name + """ + name @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting Warehouse +""" +enum WarehouseOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by code + """ + code @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by name + """ + name @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by isDefault + """ + isDefault @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting WarehouseStock +""" +enum WarehouseStockOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by warehouseId + """ + warehouseId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by variantId + """ + variantId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by quantityOnHand + """ + quantityOnHand @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting Variant +""" +enum VariantOrderField @join__type(graph: CATALOG_ADMIN) { + """ + Sort by productId + """ + productId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by isDefault + """ + isDefault @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by handle + """ + handle @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by externalSystem + """ + externalSystem @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by externalId + """ + externalId @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: CATALOG_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting FacetSourceCandidate +""" +enum FacetSourceCandidateOrderField @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + """ + Sort by facetType + """ + facetType @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + """ + Sort by handle + """ + handle @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + """ + Sort by name + """ + name @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + """ + Sort by sourceSortBucket + """ + sourceSortBucket @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + """ + Sort by sortName + """ + sortName @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) +} + +""" +Fields available for sorting FacetValueCandidate +""" +enum FacetValueCandidateOrderField @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + """ + Sort by handle + """ + handle @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + """ + Sort by label + """ + label @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) +} + +enum BulkUpdateJobStatus @join__type(graph: CATALOG_ADMIN) { + QUEUED @join__enumValue(graph: CATALOG_ADMIN) + RUNNING @join__enumValue(graph: CATALOG_ADMIN) + COMPLETED @join__enumValue(graph: CATALOG_ADMIN) + CANCELLED @join__enumValue(graph: CATALOG_ADMIN) +} + +enum WarehouseAssignableVariantOrderField @join__type(graph: CATALOG_ADMIN) { + productName @join__enumValue(graph: CATALOG_ADMIN) + sku @join__enumValue(graph: CATALOG_ADMIN) + id @join__enumValue(graph: CATALOG_ADMIN) + productId @join__enumValue(graph: CATALOG_ADMIN) + isDefault @join__enumValue(graph: CATALOG_ADMIN) + handle @join__enumValue(graph: CATALOG_ADMIN) + externalSystem @join__enumValue(graph: CATALOG_ADMIN) + externalId @join__enumValue(graph: CATALOG_ADMIN) + createdAt @join__enumValue(graph: CATALOG_ADMIN) + updatedAt @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BulkUpdateItemStatus @join__type(graph: CATALOG_ADMIN) { + PENDING @join__enumValue(graph: CATALOG_ADMIN) + RUNNING @join__enumValue(graph: CATALOG_ADMIN) + SUCCEEDED @join__enumValue(graph: CATALOG_ADMIN) + FAILED @join__enumValue(graph: CATALOG_ADMIN) + CANCELLED @join__enumValue(graph: CATALOG_ADMIN) + SUPERSEDED @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BulkUpdateOpType @join__type(graph: CATALOG_ADMIN) { + PRODUCT_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + PRODUCT_CATEGORY_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + PRODUCT_TAG_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + VARIANT_CREATE @join__enumValue(graph: CATALOG_ADMIN) + VARIANT_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + VARIANT_DELETE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BulkUpdateCancelReason @join__type(graph: CATALOG_ADMIN) { + USER @join__enumValue(graph: CATALOG_ADMIN) + SUPERSEDED @join__enumValue(graph: CATALOG_ADMIN) + SYSTEM @join__enumValue(graph: CATALOG_ADMIN) +} + +enum ProductStatusAction @join__type(graph: CATALOG_ADMIN) { + PUBLISH @join__enumValue(graph: CATALOG_ADMIN) + UNPUBLISH @join__enumValue(graph: CATALOG_ADMIN) +} + +enum ProductStatus @join__type(graph: CATALOG_ADMIN) { + DRAFT @join__enumValue(graph: CATALOG_ADMIN) + PUBLISHED @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleType @join__type(graph: CATALOG_ADMIN) { + FIXED @join__enumValue(graph: CATALOG_ADMIN) + MULTIPACK @join__enumValue(graph: CATALOG_ADMIN) + MIX_AND_MATCH @join__enumValue(graph: CATALOG_ADMIN) + CUSTOM @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleDisplayStyle @join__type(graph: CATALOG_ADMIN) { + ACCORDION @join__enumValue(graph: CATALOG_ADMIN) + TABS @join__enumValue(graph: CATALOG_ADMIN) + FLAT @join__enumValue(graph: CATALOG_ADMIN) + WIZARD @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleItemType @join__type(graph: CATALOG_ADMIN) { + PRODUCT @join__enumValue(graph: CATALOG_ADMIN) + VARIANT @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleItemOptionValueSelectionStatus @join__type(graph: CATALOG_ADMIN) { + SELECTED @join__enumValue(graph: CATALOG_ADMIN) + DESELECTED @join__enumValue(graph: CATALOG_ADMIN) + NEW @join__enumValue(graph: CATALOG_ADMIN) + UNAVAILABLE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundlePriceType @join__type(graph: CATALOG_ADMIN) { + BASE @join__enumValue(graph: CATALOG_ADMIN) + FIXED @join__enumValue(graph: CATALOG_ADMIN) + DISCOUNT_PERCENT @join__enumValue(graph: CATALOG_ADMIN) + DISCOUNT_FIXED @join__enumValue(graph: CATALOG_ADMIN) + FREE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleLogicOperator @join__type(graph: CATALOG_ADMIN) { + AND @join__enumValue(graph: CATALOG_ADMIN) + OR @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleConditionCategory @join__type(graph: CATALOG_ADMIN) { + STATE_CHECK @join__enumValue(graph: CATALOG_ADMIN) + NUMERIC @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleConditionSubject @join__type(graph: CATALOG_ADMIN) { + ITEM_SELECTED @join__enumValue(graph: CATALOG_ADMIN) + ITEM_QTY @join__enumValue(graph: CATALOG_ADMIN) + GROUP_TOTAL_QTY @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleConditionOperator @join__type(graph: CATALOG_ADMIN) { + IS_SELECTED @join__enumValue(graph: CATALOG_ADMIN) + IS_NOT_SELECTED @join__enumValue(graph: CATALOG_ADMIN) + GTE @join__enumValue(graph: CATALOG_ADMIN) + EQ @join__enumValue(graph: CATALOG_ADMIN) + LTE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleDependencyTargetType @join__type(graph: CATALOG_ADMIN) { + ITEM @join__enumValue(graph: CATALOG_ADMIN) + GROUP @join__enumValue(graph: CATALOG_ADMIN) + BUNDLE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum BundleDependencyActionType @join__type(graph: CATALOG_ADMIN) { + SHOW @join__enumValue(graph: CATALOG_ADMIN) + HIDE @join__enumValue(graph: CATALOG_ADMIN) + SET_REQUIRED @join__enumValue(graph: CATALOG_ADMIN) + ADJUST_PRICE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum ProductKind @join__type(graph: CATALOG_ADMIN) { + BASE @join__enumValue(graph: CATALOG_ADMIN) + BUNDLE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum ProductSortBy @join__type(graph: CATALOG_ADMIN) { + MANUAL @join__enumValue(graph: CATALOG_ADMIN) + PRICE @join__enumValue(graph: CATALOG_ADMIN) + NEWEST @join__enumValue(graph: CATALOG_ADMIN) + NAME @join__enumValue(graph: CATALOG_ADMIN) +} + +enum CategoryHierarchyScopeDirection @join__type(graph: CATALOG_ADMIN) { + ANCESTORS @join__enumValue(graph: CATALOG_ADMIN) + DESCENDANTS @join__enumValue(graph: CATALOG_ADMIN) +} + +enum CategoryHierarchyScopeMode @join__type(graph: CATALOG_ADMIN) { + INCLUDE @join__enumValue(graph: CATALOG_ADMIN) + EXCLUDE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum CategoryStatus @join__type(graph: CATALOG_ADMIN) { + DRAFT @join__enumValue(graph: CATALOG_ADMIN) + PUBLISHED @join__enumValue(graph: CATALOG_ADMIN) +} + +enum CollectionType @join__type(graph: CATALOG_ADMIN) { + MANUAL @join__enumValue(graph: CATALOG_ADMIN) + RULE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum InventoryItemOrderField @join__type(graph: CATALOG_ADMIN) { + productName @join__enumValue(graph: CATALOG_ADMIN) + sku @join__enumValue(graph: CATALOG_ADMIN) + quantityOnHand @join__enumValue(graph: CATALOG_ADMIN) + reservedQuantity @join__enumValue(graph: CATALOG_ADMIN) + unavailableQuantity @join__enumValue(graph: CATALOG_ADMIN) + availableForSale @join__enumValue(graph: CATALOG_ADMIN) + updatedAt @join__enumValue(graph: CATALOG_ADMIN) + id @join__enumValue(graph: CATALOG_ADMIN) + variantId @join__enumValue(graph: CATALOG_ADMIN) +} + +enum InventoryItemWarehouseScopeMode @join__type(graph: CATALOG_ADMIN) { + INCLUDE @join__enumValue(graph: CATALOG_ADMIN) + EXCLUDE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum ThresholdMethod @join__type(graph: CATALOG_ADMIN) { + SAFETY_STOCK @join__enumValue(graph: CATALOG_ADMIN) + REORDER_POINT @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Display type for product options in the UI. +""" +enum OptionDisplayType @join__type(graph: CATALOG_ADMIN) { + DROPDOWN @join__enumValue(graph: CATALOG_ADMIN) + SWATCH @join__enumValue(graph: CATALOG_ADMIN) + BUTTONS @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Type of visual swatch for option values. +""" +enum SwatchType @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) { + COLOR @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + GRADIENT @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) + IMAGE @join__enumValue(graph: CATALOG_ADMIN) @join__enumValue(graph: LISTING_ADMIN) +} + +enum ProductCategoryOperationAction @join__type(graph: CATALOG_ADMIN) { + ADD @join__enumValue(graph: CATALOG_ADMIN) + REMOVE @join__enumValue(graph: CATALOG_ADMIN) + SET_PRIMARY @join__enumValue(graph: CATALOG_ADMIN) + MOVE @join__enumValue(graph: CATALOG_ADMIN) +} + +enum ProductTagOperationAction @join__type(graph: CATALOG_ADMIN) { + ADD @join__enumValue(graph: CATALOG_ADMIN) + REMOVE @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Variant operation action in the unified product update. +""" +enum VariantOperationAction @join__type(graph: CATALOG_ADMIN) { + CREATE @join__enumValue(graph: CATALOG_ADMIN) + UPDATE @join__enumValue(graph: CATALOG_ADMIN) + DELETE @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Type of operation in the unified update. +""" +enum OperationType @join__type(graph: CATALOG_ADMIN) { + PRODUCT_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + VARIANT_CREATE @join__enumValue(graph: CATALOG_ADMIN) + VARIANT_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + VARIANT_DELETE @join__enumValue(graph: CATALOG_ADMIN) + PRODUCT_CATEGORY_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + PRODUCT_TAG_UPDATE @join__enumValue(graph: CATALOG_ADMIN) + CATEGORY_UPDATE @join__enumValue(graph: CATALOG_ADMIN) +} + +""" +Fields available for sorting Organization +""" +enum OrganizationOrderField @join__type(graph: IAM_ADMIN) { + """ + Sort by name + """ + name @join__enumValue(graph: IAM_ADMIN) + """ + Sort by displayName + """ + displayName @join__enumValue(graph: IAM_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: IAM_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: IAM_ADMIN) +} + +""" +Action level for permissions. +Hierarchy: read < write < admin +- admin includes write and read +- write includes read +""" +enum Action @join__type(graph: IAM_ADMIN) { + read @join__enumValue(graph: IAM_ADMIN) + write @join__enumValue(graph: IAM_ADMIN) + admin @join__enumValue(graph: IAM_ADMIN) +} + +enum FacetType @join__type(graph: LISTING_ADMIN) { + PRICE @join__enumValue(graph: LISTING_ADMIN) + TAG @join__enumValue(graph: LISTING_ADMIN) + FEATURE @join__enumValue(graph: LISTING_ADMIN) + OPTION @join__enumValue(graph: LISTING_ADMIN) + IN_STOCK @join__enumValue(graph: LISTING_ADMIN) +} + +enum FacetUIType @join__type(graph: LISTING_ADMIN) { + CHECKBOX @join__enumValue(graph: LISTING_ADMIN) + RADIO @join__enumValue(graph: LISTING_ADMIN) + DROPDOWN @join__enumValue(graph: LISTING_ADMIN) + RANGE @join__enumValue(graph: LISTING_ADMIN) + BOOLEAN @join__enumValue(graph: LISTING_ADMIN) +} + +enum FacetSelectionMode @join__type(graph: LISTING_ADMIN) { + SINGLE @join__enumValue(graph: LISTING_ADMIN) + MULTI @join__enumValue(graph: LISTING_ADMIN) +} + +enum FacetValueKind @join__type(graph: LISTING_ADMIN) { + SOURCE @join__enumValue(graph: LISTING_ADMIN) + GROUP @join__enumValue(graph: LISTING_ADMIN) +} + +enum FacetValueCandidateType @join__type(graph: LISTING_ADMIN) { + TAG @join__enumValue(graph: LISTING_ADMIN) + OPTION @join__enumValue(graph: LISTING_ADMIN) + FEATURE @join__enumValue(graph: LISTING_ADMIN) +} + +enum ListingScopeKind @join__type(graph: LISTING_ADMIN) { + SEARCH @join__enumValue(graph: LISTING_ADMIN) + CATEGORY @join__enumValue(graph: LISTING_ADMIN) +} + +enum ListingSortBy @join__type(graph: LISTING_ADMIN) { + MANUAL @join__enumValue(graph: LISTING_ADMIN) + RELEVANCE @join__enumValue(graph: LISTING_ADMIN) + NEWEST @join__enumValue(graph: LISTING_ADMIN) + CREATED @join__enumValue(graph: LISTING_ADMIN) + NAME @join__enumValue(graph: LISTING_ADMIN) + PRICE @join__enumValue(graph: LISTING_ADMIN) +} + +enum ListingSortDirection @join__type(graph: LISTING_ADMIN) { + asc @join__enumValue(graph: LISTING_ADMIN) + desc @join__enumValue(graph: LISTING_ADMIN) +} + +enum ListingFacetType @join__type(graph: LISTING_ADMIN) { + LIST @join__enumValue(graph: LISTING_ADMIN) + BOOLEAN @join__enumValue(graph: LISTING_ADMIN) + PRICE_RANGE @join__enumValue(graph: LISTING_ADMIN) +} + +""" +Fields available for sorting File +""" +enum FileOrderField @join__type(graph: MEDIA_ADMIN) { + """ + Sort by id + """ + id @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by provider + """ + provider @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by url + """ + url @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by mimeType + """ + mimeType @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by ext + """ + ext @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by sizeBytes + """ + sizeBytes @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by originalName + """ + originalName @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by width + """ + width @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by height + """ + height @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by durationMs + """ + durationMs @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by altText + """ + altText @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by sourceUrl + """ + sourceUrl @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by idempotencyKey + """ + idempotencyKey @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by isProcessed + """ + isProcessed @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by meta + """ + meta @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by createdAt + """ + createdAt @join__enumValue(graph: MEDIA_ADMIN) + """ + Sort by updatedAt + """ + updatedAt @join__enumValue(graph: MEDIA_ADMIN) +} + +""" +Provider type for files. +""" +enum FileProvider @join__type(graph: MEDIA_ADMIN) { + """ + File stored in S3 + """ + S3 @join__enumValue(graph: MEDIA_ADMIN) + """ + YouTube video + """ + YOUTUBE @join__enumValue(graph: MEDIA_ADMIN) + """ + Vimeo video + """ + VIMEO @join__enumValue(graph: MEDIA_ADMIN) + """ + External URL + """ + URL @join__enumValue(graph: MEDIA_ADMIN) + """ + Local file storage + """ + LOCAL @join__enumValue(graph: MEDIA_ADMIN) +} + +enum CountryCode @join__type(graph: ORDERS_ADMIN) { + """ + United States + """ + US @join__enumValue(graph: ORDERS_ADMIN) + """ + Canada + """ + CA @join__enumValue(graph: ORDERS_ADMIN) + """ + Mexico + """ + MX @join__enumValue(graph: ORDERS_ADMIN) + """ + United Kingdom + """ + GB @join__enumValue(graph: ORDERS_ADMIN) + """ + Germany + """ + DE @join__enumValue(graph: ORDERS_ADMIN) + """ + France + """ + FR @join__enumValue(graph: ORDERS_ADMIN) + """ + Italy + """ + IT @join__enumValue(graph: ORDERS_ADMIN) + """ + Spain + """ + ES @join__enumValue(graph: ORDERS_ADMIN) + """ + Australia + """ + AU @join__enumValue(graph: ORDERS_ADMIN) + """ + New Zealand + """ + NZ @join__enumValue(graph: ORDERS_ADMIN) + """ + Japan + """ + JP @join__enumValue(graph: ORDERS_ADMIN) + """ + China + """ + CN @join__enumValue(graph: ORDERS_ADMIN) + """ + South Korea + """ + KR @join__enumValue(graph: ORDERS_ADMIN) + """ + India + """ + IN @join__enumValue(graph: ORDERS_ADMIN) + """ + Brazil + """ + BR @join__enumValue(graph: ORDERS_ADMIN) + """ + Argentina + """ + AR @join__enumValue(graph: ORDERS_ADMIN) + """ + South Africa + """ + ZA @join__enumValue(graph: ORDERS_ADMIN) + """ + Russia + """ + RU @join__enumValue(graph: ORDERS_ADMIN) + """ + Ukraine + """ + UA @join__enumValue(graph: ORDERS_ADMIN) + """ + Poland + """ + PL @join__enumValue(graph: ORDERS_ADMIN) + """ + Netherlands + """ + NL @join__enumValue(graph: ORDERS_ADMIN) + """ + Belgium + """ + BE @join__enumValue(graph: ORDERS_ADMIN) + """ + Sweden + """ + SE @join__enumValue(graph: ORDERS_ADMIN) + """ + Norway + """ + NO @join__enumValue(graph: ORDERS_ADMIN) + """ + Denmark + """ + DK @join__enumValue(graph: ORDERS_ADMIN) + """ + Finland + """ + FI @join__enumValue(graph: ORDERS_ADMIN) + """ + Switzerland + """ + CH @join__enumValue(graph: ORDERS_ADMIN) + """ + Austria + """ + AT @join__enumValue(graph: ORDERS_ADMIN) + """ + Portugal + """ + PT @join__enumValue(graph: ORDERS_ADMIN) + """ + Ireland + """ + IE @join__enumValue(graph: ORDERS_ADMIN) + """ + Czech Republic + """ + CZ @join__enumValue(graph: ORDERS_ADMIN) + """ + Hungary + """ + HU @join__enumValue(graph: ORDERS_ADMIN) + """ + Slovakia + """ + SK @join__enumValue(graph: ORDERS_ADMIN) + """ + Slovenia + """ + SI @join__enumValue(graph: ORDERS_ADMIN) + """ + Croatia + """ + HR @join__enumValue(graph: ORDERS_ADMIN) + """ + Greece + """ + GR @join__enumValue(graph: ORDERS_ADMIN) + """ + Turkey + """ + TR @join__enumValue(graph: ORDERS_ADMIN) + """ + Romania + """ + RO @join__enumValue(graph: ORDERS_ADMIN) + """ + Bulgaria + """ + BG @join__enumValue(graph: ORDERS_ADMIN) + """ + Estonia + """ + EE @join__enumValue(graph: ORDERS_ADMIN) + """ + Latvia + """ + LV @join__enumValue(graph: ORDERS_ADMIN) + """ + Lithuania + """ + LT @join__enumValue(graph: ORDERS_ADMIN) + """ + Iceland + """ + IS @join__enumValue(graph: ORDERS_ADMIN) + """ + Luxembourg + """ + LU @join__enumValue(graph: ORDERS_ADMIN) + """ + Liechtenstein + """ + LI @join__enumValue(graph: ORDERS_ADMIN) + """ + Malta + """ + MT @join__enumValue(graph: ORDERS_ADMIN) + """ + Cyprus + """ + CY @join__enumValue(graph: ORDERS_ADMIN) + """ + Israel + """ + IL @join__enumValue(graph: ORDERS_ADMIN) + """ + Saudi Arabia + """ + SA @join__enumValue(graph: ORDERS_ADMIN) + """ + United Arab Emirates + """ + AE @join__enumValue(graph: ORDERS_ADMIN) + """ + Qatar + """ + QA @join__enumValue(graph: ORDERS_ADMIN) + """ + Kuwait + """ + KW @join__enumValue(graph: ORDERS_ADMIN) + """ + Oman + """ + OM @join__enumValue(graph: ORDERS_ADMIN) + """ + Jordan + """ + JO @join__enumValue(graph: ORDERS_ADMIN) + """ + Egypt + """ + EG @join__enumValue(graph: ORDERS_ADMIN) + """ + Morocco + """ + MA @join__enumValue(graph: ORDERS_ADMIN) + """ + Tunisia + """ + TN @join__enumValue(graph: ORDERS_ADMIN) + """ + Algeria + """ + DZ @join__enumValue(graph: ORDERS_ADMIN) + """ + Nigeria + """ + NG @join__enumValue(graph: ORDERS_ADMIN) + """ + Kenya + """ + KE @join__enumValue(graph: ORDERS_ADMIN) + """ + Ethiopia + """ + ET @join__enumValue(graph: ORDERS_ADMIN) + """ + Ghana + """ + GH @join__enumValue(graph: ORDERS_ADMIN) + """ + Senegal + """ + SN @join__enumValue(graph: ORDERS_ADMIN) + """ + Ivory Coast + """ + CI @join__enumValue(graph: ORDERS_ADMIN) + """ + Tanzania + """ + TZ @join__enumValue(graph: ORDERS_ADMIN) + """ + Uganda + """ + UG @join__enumValue(graph: ORDERS_ADMIN) + """ + Cameroon + """ + CM @join__enumValue(graph: ORDERS_ADMIN) + """ + Zambia + """ + ZM @join__enumValue(graph: ORDERS_ADMIN) + """ + Zimbabwe + """ + ZW @join__enumValue(graph: ORDERS_ADMIN) + """ + Mozambique + """ + MZ @join__enumValue(graph: ORDERS_ADMIN) + """ + Botswana + """ + BW @join__enumValue(graph: ORDERS_ADMIN) + """ + Namibia + """ + NA @join__enumValue(graph: ORDERS_ADMIN) + """ + Angola + """ + AO @join__enumValue(graph: ORDERS_ADMIN) + """ + Democratic Republic of the Congo + """ + CD @join__enumValue(graph: ORDERS_ADMIN) + """ + Sudan """ - ID of the deleted store, null if deletion failed + SD @join__enumValue(graph: ORDERS_ADMIN) """ - deletedStoreId: ID + Pakistan """ - List of errors that occurred during deletion + PK @join__enumValue(graph: ORDERS_ADMIN) """ - userErrors: [UserError!]! -} - -""" -A generic user error interface for mutation responses. -""" -interface UserError @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: PROJECT_ADMIN) { + Bangladesh """ - The error message. + BD @join__enumValue(graph: ORDERS_ADMIN) """ - message: String! + Nepal """ - The path to the input field that caused the error. + NP @join__enumValue(graph: ORDERS_ADMIN) """ - field: [String!] + Thailand """ - An error code for programmatic handling. + TH @join__enumValue(graph: ORDERS_ADMIN) """ - code: String -} - -""" -The Node interface is implemented by all types that have a globally unique ID. -""" -interface Node @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) { + Vietnam """ - The globally unique ID of the object. + VN @join__enumValue(graph: ORDERS_ADMIN) """ - id: ID! -} - -interface Purchasable @join__type(graph: ORDERS_ADMIN) { + Malaysia """ - Unique identifier of the purchasable entity. + MY @join__enumValue(graph: ORDERS_ADMIN) """ - id: ID! -} - -union OrderActor @join__type(graph: ORDERS_ADMIN) @join__unionMember(graph: ORDERS_ADMIN, member: "User") @join__unionMember(graph: ORDERS_ADMIN, member: "ApiKey") = User | ApiKey - -""" -Currency codes according to ISO 4217 -""" -enum CurrencyCode @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { + Singapore """ - UAE Dirham (United Arab Emirates) - 2 decimals + SG @join__enumValue(graph: ORDERS_ADMIN) """ - AED @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Indonesia """ - Afghan Afghani (Afghanistan) - 0 decimals + ID @join__enumValue(graph: ORDERS_ADMIN) """ - AFN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Philippines """ - Albanian Lek (Albania) - 0 decimals + PH @join__enumValue(graph: ORDERS_ADMIN) """ - ALL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Myanmar """ - Armenian Dram (Armenia) - 2 decimals + MM @join__enumValue(graph: ORDERS_ADMIN) """ - AMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Cambodia """ - Netherlands Antillean Guilder - 2 decimals + KH @join__enumValue(graph: ORDERS_ADMIN) """ - ANG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Laos """ - Angolan Kwanza (Angola) - 2 decimals + LA @join__enumValue(graph: ORDERS_ADMIN) """ - AOA @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Brunei """ - Argentine Peso (Argentina) - 2 decimals + BN @join__enumValue(graph: ORDERS_ADMIN) """ - ARS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Kazakhstan """ - Australian Dollar (Australia) - 2 decimals + KZ @join__enumValue(graph: ORDERS_ADMIN) """ - AUD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Uzbekistan """ - Aruban Florin (Aruba) - 2 decimals + UZ @join__enumValue(graph: ORDERS_ADMIN) """ - AWG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Turkmenistan """ - Azerbaijani Manat (Azerbaijan) - 2 decimals + TM @join__enumValue(graph: ORDERS_ADMIN) """ - AZN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Kyrgyzstan """ - Bosnia-Herzegovina Convertible Mark - 2 decimals + KG @join__enumValue(graph: ORDERS_ADMIN) """ - BAM @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Tajikistan """ - Barbadian Dollar (Barbados) - 2 decimals + TJ @join__enumValue(graph: ORDERS_ADMIN) """ - BBD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Georgia """ - Bangladeshi Taka (Bangladesh) - 2 decimals + GE @join__enumValue(graph: ORDERS_ADMIN) """ - BDT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Armenia """ - Bulgarian Lev (Bulgaria) - 2 decimals + AM @join__enumValue(graph: ORDERS_ADMIN) """ - BGN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Azerbaijan """ - Bahraini Dinar (Bahrain) - 3 decimals + AZ @join__enumValue(graph: ORDERS_ADMIN) """ - BHD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Belarus """ - Burundian Franc (Burundi) - 0 decimals + BY @join__enumValue(graph: ORDERS_ADMIN) """ - BIF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Moldova """ - Bermudian Dollar (Bermuda) - 2 decimals + MD @join__enumValue(graph: ORDERS_ADMIN) """ - BMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Serbia """ - Brunei Dollar (Brunei) - 2 decimals + RS @join__enumValue(graph: ORDERS_ADMIN) """ - BND @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Montenegro """ - Bolivian Boliviano (Bolivia) - 2 decimals + ME @join__enumValue(graph: ORDERS_ADMIN) """ - BOB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + North Macedonia """ - Brazilian Real (Brazil) - 2 decimals + MK @join__enumValue(graph: ORDERS_ADMIN) """ - BRL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Bosnia and Herzegovina """ - Bahamian Dollar (Bahamas) - 2 decimals + BA @join__enumValue(graph: ORDERS_ADMIN) """ - BSD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Albania """ - Bhutanese Ngultrum (Bhutan) - 2 decimals + AL @join__enumValue(graph: ORDERS_ADMIN) """ - BTN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Kosovo """ - Botswana Pula (Botswana) - 2 decimals + XK @join__enumValue(graph: ORDERS_ADMIN) """ - BWP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Greenland """ - Belarusian Ruble (Belarus) - 2 decimals + GL @join__enumValue(graph: ORDERS_ADMIN) """ - BYN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Panama """ - Belize Dollar (Belize) - 2 decimals + PA @join__enumValue(graph: ORDERS_ADMIN) """ - BZD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Costa Rica """ - Canadian Dollar (Canada) - 2 decimals + CR @join__enumValue(graph: ORDERS_ADMIN) """ - CAD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + El Salvador """ - Congolese Franc (DR Congo) - 2 decimals + SV @join__enumValue(graph: ORDERS_ADMIN) """ - CDF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Guatemala """ - Swiss Franc (Switzerland) - 2 decimals + GT @join__enumValue(graph: ORDERS_ADMIN) """ - CHF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Honduras """ - Chilean Peso (Chile) - 0 decimals + HN @join__enumValue(graph: ORDERS_ADMIN) """ - CLP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Nicaragua """ - Chinese Yuan (China) - 2 decimals + NI @join__enumValue(graph: ORDERS_ADMIN) """ - CNY @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Jamaica """ - Colombian Peso (Colombia) - 2 decimals + JM @join__enumValue(graph: ORDERS_ADMIN) """ - COP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Cuba """ - Costa Rican Colon (Costa Rica) - 2 decimals + CU @join__enumValue(graph: ORDERS_ADMIN) """ - CRC @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Dominican Republic """ - Cuban Peso (Cuba) - 2 decimals + DO @join__enumValue(graph: ORDERS_ADMIN) """ - CUP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Haiti """ - Cape Verdean Escudo (Cape Verde) - 2 decimals + HT @join__enumValue(graph: ORDERS_ADMIN) """ - CVE @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Trinidad and Tobago """ - Czech Koruna (Czech Republic) - 2 decimals + TT @join__enumValue(graph: ORDERS_ADMIN) """ - CZK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Barbados """ - Djiboutian Franc (Djibouti) - 0 decimals + BB @join__enumValue(graph: ORDERS_ADMIN) """ - DJF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Bahamas """ - Danish Krone (Denmark) - 2 decimals + BS @join__enumValue(graph: ORDERS_ADMIN) """ - DKK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Paraguay """ - Dominican Peso (Dominican Republic) - 2 decimals + PY @join__enumValue(graph: ORDERS_ADMIN) """ - DOP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Uruguay """ - Algerian Dinar (Algeria) - 2 decimals + UY @join__enumValue(graph: ORDERS_ADMIN) """ - DZD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Chile """ - Egyptian Pound (Egypt) - 2 decimals + CL @join__enumValue(graph: ORDERS_ADMIN) """ - EGP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Peru """ - Eritrean Nakfa (Eritrea) - 2 decimals + PE @join__enumValue(graph: ORDERS_ADMIN) """ - ERN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Colombia """ - Ethiopian Birr (Ethiopia) - 2 decimals + CO @join__enumValue(graph: ORDERS_ADMIN) """ - ETB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Venezuela """ - Euro (European Union) - 2 decimals + VE @join__enumValue(graph: ORDERS_ADMIN) """ - EUR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Bolivia """ - Fijian Dollar (Fiji) - 2 decimals + BO @join__enumValue(graph: ORDERS_ADMIN) """ - FJD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Ecuador """ - Falkland Islands Pound - 2 decimals + EC @join__enumValue(graph: ORDERS_ADMIN) """ - FKP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Suriname """ - Faroese Króna (Faroe Islands) - 2 decimals + SR @join__enumValue(graph: ORDERS_ADMIN) """ - FOK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Guyana """ - Pound Sterling (United Kingdom) - 2 decimals + GY @join__enumValue(graph: ORDERS_ADMIN) """ - GBP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Fiji """ - Georgian Lari (Georgia) - 2 decimals + FJ @join__enumValue(graph: ORDERS_ADMIN) """ - GEL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Papua New Guinea """ - Guernsey Pound (Guernsey) - 2 decimals + PG @join__enumValue(graph: ORDERS_ADMIN) """ - GGP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Samoa """ - Ghanaian Cedi (Ghana) - 2 decimals + WS @join__enumValue(graph: ORDERS_ADMIN) """ - GHS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Tonga """ - Gibraltar Pound (Gibraltar) - 2 decimals + TO @join__enumValue(graph: ORDERS_ADMIN) """ - GIP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Solomon Islands """ - Gambian Dalasi (Gambia) - 2 decimals + SB @join__enumValue(graph: ORDERS_ADMIN) """ - GMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Vanuatu """ - Guinean Franc (Guinea) - 0 decimals + VU @join__enumValue(graph: ORDERS_ADMIN) """ - GNF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + New Caledonia """ - Guatemalan Quetzal (Guatemala) - 2 decimals + NC @join__enumValue(graph: ORDERS_ADMIN) """ - GTQ @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Marshall Islands """ - Guyanese Dollar (Guyana) - 2 decimals + MH @join__enumValue(graph: ORDERS_ADMIN) """ - GYD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Micronesia """ - Hong Kong Dollar (Hong Kong) - 2 decimals + FM @join__enumValue(graph: ORDERS_ADMIN) """ - HKD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Palau """ - Honduran Lempira (Honduras) - 2 decimals + PW @join__enumValue(graph: ORDERS_ADMIN) """ - HNL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Maldives """ - Croatian Kuna (Croatia) - 2 decimals + MV @join__enumValue(graph: ORDERS_ADMIN) """ - HRK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Seychelles """ - Haitian Gourde (Haiti) - 2 decimals + SC @join__enumValue(graph: ORDERS_ADMIN) """ - HTG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Mauritius """ - Hungarian Forint (Hungary) - 2 decimals + MU @join__enumValue(graph: ORDERS_ADMIN) """ - HUF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Sri Lanka """ - Indonesian Rupiah (Indonesia) - 0 decimals + LK @join__enumValue(graph: ORDERS_ADMIN) """ - IDR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Bhutan """ - Israeli New Shekel (Israel) - 2 decimals + BT @join__enumValue(graph: ORDERS_ADMIN) """ - ILS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Mongolia """ - Isle of Man Pound - 2 decimals + MN @join__enumValue(graph: ORDERS_ADMIN) """ - IMP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + North Korea """ - Indian Rupee (India) - 2 decimals + KP @join__enumValue(graph: ORDERS_ADMIN) """ - INR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Iraq """ - Iraqi Dinar (Iraq) - 3 decimals + IQ @join__enumValue(graph: ORDERS_ADMIN) """ - IQD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Iran """ - Iranian Rial (Iran) - 2 decimals + IR @join__enumValue(graph: ORDERS_ADMIN) """ - IRR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Afghanistan """ - Icelandic Króna (Iceland) - 0 decimals + AF @join__enumValue(graph: ORDERS_ADMIN) """ - ISK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Yemen """ - Jersey Pound (Jersey) - 2 decimals + YE @join__enumValue(graph: ORDERS_ADMIN) """ - JEP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Syria """ - Jamaican Dollar (Jamaica) - 2 decimals + SY @join__enumValue(graph: ORDERS_ADMIN) + """ + Lebanon """ - JMD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + LB @join__enumValue(graph: ORDERS_ADMIN) """ - Jordanian Dinar (Jordan) - 3 decimals + Palestine """ - JOD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + PS @join__enumValue(graph: ORDERS_ADMIN) """ - Japanese Yen (Japan) - 0 decimals + Bahrain """ - JPY @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + BH @join__enumValue(graph: ORDERS_ADMIN) """ - Kenyan Shilling (Kenya) - 2 decimals + Malawi """ - KES @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + MW @join__enumValue(graph: ORDERS_ADMIN) """ - Kyrgyzstani Som (Kyrgyzstan) - 2 decimals + Rwanda """ - KGS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + RW @join__enumValue(graph: ORDERS_ADMIN) """ - Cambodian Riel (Cambodia) - 2 decimals + Burundi """ - KHR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + BI @join__enumValue(graph: ORDERS_ADMIN) """ - Comorian Franc (Comoros) - 2 decimals + South Sudan """ - KMF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + SS @join__enumValue(graph: ORDERS_ADMIN) """ - North Korean Won (North Korea) - 2 decimals + Lesotho """ - KPW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + LS @join__enumValue(graph: ORDERS_ADMIN) """ - South Korean Won (South Korea) - 0 decimals + Swaziland (Eswatini) """ - KRW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + SZ @join__enumValue(graph: ORDERS_ADMIN) """ - Kuwaiti Dinar (Kuwait) - 3 decimals + Madagascar """ - KWD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + MG @join__enumValue(graph: ORDERS_ADMIN) """ - Cayman Islands Dollar - 2 decimals + Central African Republic """ - KYD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + CF @join__enumValue(graph: ORDERS_ADMIN) """ - Kazakhstani Tenge (Kazakhstan) - 2 decimals + Republic of the Congo """ - KZT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + CG @join__enumValue(graph: ORDERS_ADMIN) """ - Lao Kip (Laos) - 2 decimals + Gabon """ - LAK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + GA @join__enumValue(graph: ORDERS_ADMIN) """ - Lebanese Pound (Lebanon) - 2 decimals + Guinea """ - LBP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + GN @join__enumValue(graph: ORDERS_ADMIN) """ - Sri Lankan Rupee (Sri Lanka) - 2 decimals + Guinea-Bissau """ - LKR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + GW @join__enumValue(graph: ORDERS_ADMIN) """ - Liberian Dollar (Liberia) - 2 decimals + Equatorial Guinea """ - LRD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + GQ @join__enumValue(graph: ORDERS_ADMIN) """ - Lesotho Loti (Lesotho) - 2 decimals + Sierra Leone """ - LSL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + SL @join__enumValue(graph: ORDERS_ADMIN) """ - Libyan Dinar (Libya) - 3 decimals + Liberia """ - LYD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + LR @join__enumValue(graph: ORDERS_ADMIN) """ - Moroccan Dirham (Morocco) - 2 decimals + Benin """ - MAD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + BJ @join__enumValue(graph: ORDERS_ADMIN) """ - Moldovan Leu (Moldova) - 2 decimals + Togo """ - MDL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + TG @join__enumValue(graph: ORDERS_ADMIN) """ - Malagasy Ariary (Madagascar) - 2 decimals + Niger """ - MGA @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + NE @join__enumValue(graph: ORDERS_ADMIN) """ - Macedonian Denar (North Macedonia) - 2 decimals + Mali """ - MKD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + ML @join__enumValue(graph: ORDERS_ADMIN) """ - Burmese Kyat (Myanmar) - 2 decimals + Burkina Faso """ - MMK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + BF @join__enumValue(graph: ORDERS_ADMIN) """ - Mongolian Tögrög (Mongolia) - 2 decimals + Chad """ - MNT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + TD @join__enumValue(graph: ORDERS_ADMIN) """ - Macanese Pataca (Macau) - 2 decimals + Mauritania """ - MOP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + MR @join__enumValue(graph: ORDERS_ADMIN) """ - Mauritanian Ouguiya (Mauritania) - 2 decimals + Gambia """ - MRU @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + GM @join__enumValue(graph: ORDERS_ADMIN) """ - Mauritian Rupee (Mauritius) - 2 decimals + Cape Verde """ - MUR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + CV @join__enumValue(graph: ORDERS_ADMIN) """ - Maldivian Rufiyaa (Maldives) - 2 decimals + Eritrea """ - MVR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + ER @join__enumValue(graph: ORDERS_ADMIN) """ - Malawian Kwacha (Malawi) - 2 decimals + Djibouti """ - MWK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + DJ @join__enumValue(graph: ORDERS_ADMIN) """ - Mexican Peso (Mexico) - 2 decimals + Comoros """ - MXN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + KM @join__enumValue(graph: ORDERS_ADMIN) """ - Malaysian Ringgit (Malaysia) - 2 decimals + Andorra """ - MYR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + AD @join__enumValue(graph: ORDERS_ADMIN) """ - Mozambican Metical (Mozambique) - 2 decimals + Monaco """ - MZN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + MC @join__enumValue(graph: ORDERS_ADMIN) """ - Namibian Dollar (Namibia) - 2 decimals + San Marino """ - NAD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + SM @join__enumValue(graph: ORDERS_ADMIN) """ - Nigerian Naira (Nigeria) - 2 decimals + Vatican City """ - NGN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + VA @join__enumValue(graph: ORDERS_ADMIN) """ - Nicaraguan Córdoba (Nicaragua) - 2 decimals + Timor-Leste (East Timor) """ - NIO @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + TL @join__enumValue(graph: ORDERS_ADMIN) """ - Norwegian Krone (Norway) - 2 decimals + Antigua and Barbuda """ - NOK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + AG @join__enumValue(graph: ORDERS_ADMIN) """ - Nepalese Rupee (Nepal) - 2 decimals + Saint Kitts and Nevis """ - NPR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + KN @join__enumValue(graph: ORDERS_ADMIN) """ - New Zealand Dollar (New Zealand) - 2 decimals + Saint Lucia """ - NZD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + LC @join__enumValue(graph: ORDERS_ADMIN) """ - Omani Rial (Oman) - 3 decimals + Saint Vincent and the Grenadines """ - OMR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + VC @join__enumValue(graph: ORDERS_ADMIN) """ - Panamanian Balboa (Panama) - 2 decimals + Grenada """ - PAB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + GD @join__enumValue(graph: ORDERS_ADMIN) """ - Peruvian Sol (Peru) - 2 decimals + Dominica """ - PEN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + DM @join__enumValue(graph: ORDERS_ADMIN) """ - Papua New Guinean Kina - 2 decimals + Belize """ - PGK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + BZ @join__enumValue(graph: ORDERS_ADMIN) """ - Philippine Peso (Philippines) - 2 decimals + Aruba """ - PHP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + AW @join__enumValue(graph: ORDERS_ADMIN) """ - Pakistani Rupee (Pakistan) - 2 decimals + Curaçao """ - PKR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + CW @join__enumValue(graph: ORDERS_ADMIN) """ - Polish Zloty (Poland) - 2 decimals + Bermuda """ - PLN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + BM @join__enumValue(graph: ORDERS_ADMIN) """ - Paraguayan Guaraní (Paraguay) - 0 decimals + Faroe Islands """ - PYG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + FO @join__enumValue(graph: ORDERS_ADMIN) """ - Qatari Riyal (Qatar) - 2 decimals + Isle of Man """ - QAR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + IM @join__enumValue(graph: ORDERS_ADMIN) """ - Romanian Leu (Romania) - 2 decimals + Jersey """ - RON @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + JE @join__enumValue(graph: ORDERS_ADMIN) """ - Serbian Dinar (Serbia) - 2 decimals + Guernsey """ - RSD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + GG @join__enumValue(graph: ORDERS_ADMIN) """ - Russian Ruble (Russia) - 2 decimals + Åland Islands """ - RUB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + AX @join__enumValue(graph: ORDERS_ADMIN) """ - Rwandan Franc (Rwanda) - 0 decimals + Western Sahara """ - RWF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + EH @join__enumValue(graph: ORDERS_ADMIN) """ - Saudi Riyal (Saudi Arabia) - 2 decimals + British Virgin Islands """ - SAR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + VG @join__enumValue(graph: ORDERS_ADMIN) """ - Solomon Islands Dollar - 2 decimals + US Virgin Islands """ - SBD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + VI @join__enumValue(graph: ORDERS_ADMIN) +} + +enum OrderStatus @join__type(graph: ORDERS_ADMIN) { + DRAFT @join__enumValue(graph: ORDERS_ADMIN) + ACTIVE @join__enumValue(graph: ORDERS_ADMIN) + CLOSED @join__enumValue(graph: ORDERS_ADMIN) + CANCELLED @join__enumValue(graph: ORDERS_ADMIN) +} + +enum OrderCancelReason @join__type(graph: ORDERS_ADMIN) { + CUSTOMER @join__enumValue(graph: ORDERS_ADMIN) + FRAUD @join__enumValue(graph: ORDERS_ADMIN) + INVENTORY @join__enumValue(graph: ORDERS_ADMIN) + STAFF @join__enumValue(graph: ORDERS_ADMIN) + OTHER @join__enumValue(graph: ORDERS_ADMIN) +} + +enum OrderEventType @join__type(graph: ORDERS_ADMIN) { + ORDER_CREATED @join__enumValue(graph: ORDERS_ADMIN) +} + +""" +Status of a store +""" +enum StoreStatus @join__type(graph: PROJECT_ADMIN) { """ - Seychelles Rupee (Seychelles) - 2 decimals + Store is active and operational """ - SCR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + ACTIVE @join__enumValue(graph: PROJECT_ADMIN) """ - Sudanese Pound (Sudan) - 2 decimals + Store is inactive and not processing requests """ - SDG @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + INACTIVE @join__enumValue(graph: PROJECT_ADMIN) +} + +""" +Filter operators for String fields +""" +input StringFilter @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) { """ - Swedish Krona (Sweden) - 2 decimals + Equals """ - SEK @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _eq: String """ - Singapore Dollar (Singapore) - 2 decimals + Not equals """ - SGD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _neq: String """ - Saint Helena Pound - 2 decimals + In array """ - SHP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _in: [String!] """ - Sierra Leonean Leone - 2 decimals + Not in array """ - SLE @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _notIn: [String!] """ - Somali Shilling (Somalia) - 2 decimals + Is null """ - SOS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _is: Boolean """ - Surinamese Dollar (Suriname) - 2 decimals + Is not null """ - SRD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _isNot: Boolean """ - South Sudanese Pound - 2 decimals + Contains substring (case-sensitive) """ - SSP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _contains: String """ - São Tomé and Príncipe Dobra - 2 decimals + Contains substring (case-insensitive) """ - STN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _containsi: String """ - Salvadoran Colón (El Salvador) - 2 decimals + Starts with (case-sensitive) """ - SVC @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _startsWith: String """ - Syrian Pound (Syria) - 2 decimals + Starts with (case-insensitive) """ - SYP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _startsWithi: String +} + +""" +Filter operators for ID fields +""" +input IDFilter @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) { """ - Eswatini Lilangeni (Eswatini) - 2 decimals + Equals """ - SZL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _eq: ID """ - Thai Baht (Thailand) - 2 decimals + Not equals """ - THB @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _neq: ID """ - Tajikistani Somoni (Tajikistan) - 2 decimals + In array """ - TJS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _in: [ID!] """ - Turkmenistani Manat (Turkmenistan) - 2 decimals + Not in array """ - TMT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _notIn: [ID!] """ - Tunisian Dinar (Tunisia) - 3 decimals + Is null """ - TND @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _is: Boolean """ - Tongan Paʻanga (Tonga) - 2 decimals + Is not null """ - TOP @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _isNot: Boolean +} + +""" +Filter operators for Int fields +""" +input IntFilter @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) @join__type(graph: MEDIA_ADMIN) { """ - Turkish Lira (Turkey) - 2 decimals + Equals """ - TRY @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _eq: Int """ - Trinidad and Tobago Dollar - 2 decimals + Not equals """ - TTD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _neq: Int """ - New Taiwan Dollar (Taiwan) - 2 decimals + Greater than """ - TWD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _gt: Int """ - Tanzanian Shilling (Tanzania) - 2 decimals + Greater than or equal """ - TZS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _gte: Int """ - Ukrainian Hryvnia (Ukraine) - 2 decimals + Less than """ - UAH @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _lt: Int """ - Ugandan Shilling (Uganda) - 0 decimals + Less than or equal """ - UGX @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _lte: Int """ - United States Dollar (USA) - 2 decimals + In array """ - USD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _in: [Int!] """ - Uruguayan Peso (Uruguay) - 2 decimals + Not in array """ - UYU @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _notIn: [Int!] """ - Uzbekistani Som (Uzbekistan) - 2 decimals + Is null """ - UZS @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _is: Boolean """ - Venezuelan Bolívar (Venezuela) - 2 decimals + Is not null """ - VES @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _isNot: Boolean """ - Vietnamese Dong (Vietnam) - 0 decimals + Between range (inclusive) """ - VND @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _between: [Int!] +} + +""" +Filter operators for Float fields +""" +input FloatFilter @join__type(graph: CATALOG_ADMIN) @join__type(graph: MEDIA_ADMIN) { """ - Vanuatu Vatu (Vanuatu) - 0 decimals + Equals """ - VUV @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _eq: Float """ - Samoan Tala (Samoa) - 2 decimals + Not equals """ - WST @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _neq: Float """ - Central African CFA Franc - 0 decimals + Greater than """ - XAF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _gt: Float """ - East Caribbean Dollar - 2 decimals + Greater than or equal """ - XCD @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _gte: Float """ - Special Drawing Rights (IMF) - 0 decimals + Less than """ - XDR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _lt: Float """ - West African CFA Franc - 0 decimals + Less than or equal """ - XOF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _lte: Float """ - CFP Franc - 0 decimals + In array """ - XPF @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _in: [Float!] """ - Yemeni Rial (Yemen) - 2 decimals + Not in array """ - YER @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _notIn: [Float!] """ - South African Rand (South Africa) - 2 decimals + Is null """ - ZAR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _is: Boolean """ - Zambian Kwacha (Zambia) - 2 decimals + Is not null """ - ZMW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _isNot: Boolean """ - Zimbabwean Dollar (Zimbabwe) - 2 decimals + Between range (inclusive) """ - ZWL @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _between: [Float!] } """ -Language/Locale codes based on ISO 639-1 and BCP 47 +Filter operators for Boolean fields """ -enum LocaleCode @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { +input BooleanFilter @join__type(graph: CATALOG_ADMIN) @join__type(graph: MEDIA_ADMIN) { """ - Akan + Equals """ - ak @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _eq: Boolean """ - Albanian + Not equals """ - sq @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _neq: Boolean """ - Amharic + Is null """ - am @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _is: Boolean """ - Arabic + Is not null """ - ar @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _isNot: Boolean +} + +""" +Filter operators for DateTime fields +""" +input DateTimeFilter @join__type(graph: CATALOG_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: MEDIA_ADMIN) { """ - Armenian + Equals """ - hy @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _eq: DateTime """ - Assamese + Not equals """ - as @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _neq: DateTime """ - Azerbaijani + Greater than (after) """ - az @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _gt: DateTime """ - Bambara + Greater than or equal (on or after) """ - bm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _gte: DateTime """ - Bangla + Less than (before) """ - bn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _lt: DateTime """ - Basque + Less than or equal (on or before) """ - eu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _lte: DateTime """ - Belarusian + Is null """ - be @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _is: Boolean """ - Bosnian + Is not null """ - bs @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _isNot: Boolean +} + +""" +Filter conditions for Product +""" +input ProductWhereInput @join__type(graph: CATALOG_ADMIN) { """ - Breton + Logical AND of multiple conditions """ - br @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _and: [ProductWhereInput!] """ - Bulgarian + Logical OR of multiple conditions """ - bg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _or: [ProductWhereInput!] """ - Burmese + Negate the condition """ - my @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _not: ProductWhereInput """ - Catalan + Filter by id """ - ca @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + id: IDFilter """ - Central Kurdish + Filter by vendorId """ - ckb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + vendorId: IDFilter """ - Chechen + Filter by handle """ - ce @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + handle: StringFilter """ - Chinese (Simplified) + Filter by publishedAt """ - zh_CN @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + publishedAt: DateTimeFilter """ - Chinese (Traditional) + Filter by createdAt """ - zh_TW @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + createdAt: DateTimeFilter """ - Cornish + Filter by updatedAt """ - kw @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + updatedAt: DateTimeFilter """ - Croatian + Filter by locale """ - hr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + locale: StringFilter """ - Czech + Filter by name """ - cs @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + name: StringFilter """ - Danish + Filter by currency """ - da @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + currency: StringFilter """ - Dutch + Filter by minAmountMinor """ - nl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + minAmountMinor: IntFilter """ - Dzongkha + Filter by maxAmountMinor """ - dz @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + maxAmountMinor: IntFilter """ - English + Filter by minPriceMinor """ - en @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + minPriceMinor: IntFilter """ - Esperanto + Filter by maxPriceMinor """ - eo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + maxPriceMinor: IntFilter """ - Estonian + Filter by primaryCategoryId """ - et @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + primaryCategoryId: IDFilter """ - Ewe + Filter by primaryCategoryName """ - ee @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + primaryCategoryName: StringFilter """ - Faroese + Filter by brandName """ - fo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + brandName: StringFilter +} + +""" +Ordering configuration for Product +""" +input ProductOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - Filipino + Field to order by """ - fil @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + field: ProductOrderField! """ - Finnish + Sort direction """ - fi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + direction: SortDirection! +} + +""" +Filter conditions for Bundle +""" +input BundleWhereInput @join__type(graph: CATALOG_ADMIN) { """ - French + Logical AND of multiple conditions """ - fr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _and: [BundleWhereInput!] """ - Fulah + Logical OR of multiple conditions """ - ff @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _or: [BundleWhereInput!] """ - Galician + Negate the condition """ - gl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _not: BundleWhereInput """ - Ganda + Filter by id """ - lg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + id: IDFilter """ - Georgian + Filter by vendorId """ - ka @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + vendorId: IDFilter """ - German + Filter by handle """ - de @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + handle: StringFilter """ - Greek + Filter by publishedAt """ - el @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + publishedAt: DateTimeFilter """ - Gujarati + Filter by createdAt """ - gu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + createdAt: DateTimeFilter """ - Hausa + Filter by updatedAt """ - ha @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + updatedAt: DateTimeFilter """ - Hebrew + Filter by locale """ - he @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + locale: StringFilter """ - Hindi + Filter by name """ - hi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + name: StringFilter """ - Hungarian + Filter by currency """ - hu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + currency: StringFilter """ - Icelandic + Filter by minAmountMinor """ - is @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + minAmountMinor: IntFilter """ - Igbo + Filter by maxAmountMinor """ - ig @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + maxAmountMinor: IntFilter """ - Indonesian + Filter by minPriceMinor """ - id @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + minPriceMinor: IntFilter """ - Interlingua + Filter by maxPriceMinor """ - ia @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + maxPriceMinor: IntFilter """ - Irish + Filter by primaryCategoryId """ - ga @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + primaryCategoryId: IDFilter """ - Italian + Filter by primaryCategoryName """ - it @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + primaryCategoryName: StringFilter """ - Japanese + Filter by brandName """ - ja @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + brandName: StringFilter """ - Javanese + Filter by bundleType + """ + bundleType: StringFilter +} + +""" +Ordering configuration for Bundle +""" +input BundleOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - jv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Field to order by """ - Kalaallisut + field: BundleOrderField! """ - kl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Sort direction """ - Kannada + direction: SortDirection! +} + +""" +Filter conditions for Vendor +""" +input VendorWhereInput @join__type(graph: CATALOG_ADMIN) { """ - kn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Logical AND of multiple conditions """ - Kashmiri + _and: [VendorWhereInput!] """ - ks @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Logical OR of multiple conditions """ - Kazakh + _or: [VendorWhereInput!] """ - kk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Negate the condition """ - Khmer + _not: VendorWhereInput """ - km @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by id """ - Kikuyu + id: IDFilter """ - ki @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by name """ - Kinyarwanda + name: StringFilter +} + +""" +Ordering configuration for Vendor +""" +input VendorOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - rw @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Field to order by """ - Korean + field: VendorOrderField! """ - ko @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Sort direction """ - Kurdish + direction: SortDirection! +} + +""" +Filter conditions for Category +""" +input CategoryWhereInput @join__type(graph: CATALOG_ADMIN) { """ - ku @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Logical AND of multiple conditions """ - Kyrgyz + _and: [CategoryWhereInput!] """ - ky @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Logical OR of multiple conditions """ - Lao + _or: [CategoryWhereInput!] """ - lo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Negate the condition """ - Latvian + _not: CategoryWhereInput """ - lv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by id """ - Lingala + id: IDFilter """ - ln @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by parentId """ - Lithuanian + parentId: IDFilter """ - lt @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by path """ - Luba-Katanga + path: StringFilter """ - lu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by depth """ - Luxembourgish + depth: IntFilter """ - lb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by handle """ - Macedonian + handle: StringFilter """ - mk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by defaultSort """ - Malagasy + defaultSort: StringFilter """ - mg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by defaultSortDirection """ - Malay + defaultSortDirection: StringFilter """ - ms @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by publishedAt """ - Malayalam + publishedAt: DateTimeFilter """ - ml @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by createdAt """ - Maltese + createdAt: DateTimeFilter """ - mt @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by updatedAt """ - Manx + updatedAt: DateTimeFilter """ - gv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by productsCount """ - Marathi + productsCount: IntFilter """ - mr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + Filter by locale """ - Mongolian + locale: StringFilter + """ + Filter by name """ - mn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + name: StringFilter +} + +""" +Ordering configuration for Category +""" +input CategoryOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - Māori + Field to order by """ - mi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + field: CategoryOrderField! """ - Nepali + Sort direction """ - ne @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + direction: SortDirection! +} + +""" +Filter conditions for Tag +""" +input TagWhereInput @join__type(graph: CATALOG_ADMIN) { """ - North Ndebele + Logical AND of multiple conditions """ - nd @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _and: [TagWhereInput!] """ - Northern Sami + Logical OR of multiple conditions """ - se @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _or: [TagWhereInput!] """ - Norwegian + Negate the condition """ - no @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _not: TagWhereInput """ - Norwegian Bokmål + Filter by storeId """ - nb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + storeId: IDFilter """ - Norwegian Nynorsk + Filter by id """ - nn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + id: IDFilter """ - Odia + Filter by handle """ - or @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + handle: StringFilter """ - Oromo + Filter by createdAt """ - om @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + createdAt: DateTimeFilter """ - Ossetic + Filter by productsCount """ - os @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + productsCount: IntFilter """ - Pashto + Filter by locale """ - ps @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + locale: StringFilter """ - Persian + Filter by name """ - fa @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + name: StringFilter +} + +""" +Ordering configuration for Tag +""" +input TagOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - Polish + Field to order by """ - pl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + field: TagOrderField! """ - Portuguese (Brazil) + Sort direction """ - pt_BR @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + direction: SortDirection! +} + +""" +Filter conditions for Warehouse +""" +input WarehouseWhereInput @join__type(graph: CATALOG_ADMIN) { """ - Portuguese (Portugal) + Logical AND of multiple conditions """ - pt_PT @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _and: [WarehouseWhereInput!] """ - Punjabi + Logical OR of multiple conditions """ - pa @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _or: [WarehouseWhereInput!] """ - Quechua + Negate the condition """ - qu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _not: WarehouseWhereInput """ - Romanian + Filter by id """ - ro @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + id: IDFilter """ - Romansh + Filter by code """ - rm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + code: StringFilter """ - Rundi + Filter by name """ - rn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + name: StringFilter """ - Russian + Filter by isDefault """ - ru @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + isDefault: BooleanFilter """ - Sango + Filter by createdAt """ - sg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + createdAt: DateTimeFilter """ - Sanskrit + Filter by updatedAt """ - sa @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + updatedAt: DateTimeFilter +} + +""" +Ordering configuration for Warehouse +""" +input WarehouseOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - Sardinian + Field to order by """ - sc @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + field: WarehouseOrderField! """ - Scottish Gaelic + Sort direction """ - gd @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + direction: SortDirection! +} + +""" +Relay-style pagination input for Warehouse +""" +input WarehouseConnectionInput @join__type(graph: CATALOG_ADMIN) { """ - Serbian + Returns the first n items """ - sr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + first: Int """ - Shona + Returns items after this cursor """ - sn @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + after: String """ - Sichuan Yi + Returns the last n items """ - ii @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + last: Int """ - Sindhi + Returns items before this cursor """ - sd @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + before: String """ - Sinhala + Filter conditions """ - si @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + where: WarehouseWhereInput """ - Slovak + Sort order """ - sk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + orderBy: [WarehouseOrderByInput!] +} + +""" +Filter conditions for WarehouseStock +""" +input WarehouseStockWhereInput @join__type(graph: CATALOG_ADMIN) { """ - Slovenian + Logical AND of multiple conditions """ - sl @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _and: [WarehouseStockWhereInput!] """ - Somali + Logical OR of multiple conditions """ - so @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _or: [WarehouseStockWhereInput!] """ - Spanish + Negate the condition """ - es @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _not: WarehouseStockWhereInput """ - Sundanese + Filter by id """ - su @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + id: IDFilter """ - Swahili + Filter by warehouseId """ - sw @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + warehouseId: IDFilter """ - Swedish + Filter by variantId """ - sv @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + variantId: IDFilter """ - Tajik + Filter by quantityOnHand """ - tg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + quantityOnHand: IntFilter """ - Tamil + Filter by updatedAt """ - ta @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + updatedAt: DateTimeFilter """ - Tatar + Filter by createdAt """ - tt @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + createdAt: DateTimeFilter +} + +""" +Ordering configuration for WarehouseStock +""" +input WarehouseStockOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - Telugu + Field to order by """ - te @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + field: WarehouseStockOrderField! """ - Thai + Sort direction """ - th @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + direction: SortDirection! +} + +""" +Relay-style pagination input for WarehouseStock +""" +input WarehouseStockConnectionInput @join__type(graph: CATALOG_ADMIN) { """ - Tibetan + Returns the first n items """ - bo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + first: Int """ - Tigrinya + Returns items after this cursor """ - ti @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + after: String """ - Tongan + Returns the last n items """ - to @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + last: Int """ - Turkish + Returns items before this cursor """ - tr @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + before: String """ - Turkmen + Filter conditions """ - tk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + where: WarehouseStockWhereInput """ - Ukrainian + Sort order """ - uk @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + orderBy: [WarehouseStockOrderByInput!] +} + +""" +Filter conditions for Variant +""" +input VariantWhereInput @join__type(graph: CATALOG_ADMIN) { """ - Urdu + Logical AND of multiple conditions """ - ur @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _and: [VariantWhereInput!] """ - Uyghur + Logical OR of multiple conditions """ - ug @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _or: [VariantWhereInput!] """ - Uzbek + Negate the condition """ - uz @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _not: VariantWhereInput """ - Vietnamese + Filter by productId """ - vi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + productId: IDFilter """ - Welsh + Filter by id """ - cy @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + id: IDFilter """ - Western Frisian + Filter by isDefault """ - fy @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + isDefault: BooleanFilter """ - Wolof + Filter by handle """ - wo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + handle: StringFilter """ - Xhosa + Filter by externalSystem """ - xh @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + externalSystem: StringFilter """ - Yiddish + Filter by externalId """ - yi @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + externalId: StringFilter """ - Yoruba + Filter by updatedAt """ - yo @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + updatedAt: DateTimeFilter """ - Zulu + Filter by createdAt """ - zu @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + createdAt: DateTimeFilter } """ -Weight measurement units +Ordering configuration for Variant """ -enum WeightUnit @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { - """ - Kilogram +input VariantOrderByInput @join__type(graph: CATALOG_ADMIN) { """ - kg @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) - """ - Gram - """ - g @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) - """ - Pound + Field to order by """ - lb @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + field: VariantOrderField! """ - Ounce + Sort direction """ - oz @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + direction: SortDirection! } """ -Dimension (length) measurement units +Filter conditions for FacetSourceCandidate """ -enum DimensionUnit @join__type(graph: APPS_ADMIN) @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) @join__type(graph: MEDIA_ADMIN) @join__type(graph: ORDERS_ADMIN) @join__type(graph: PROJECT_ADMIN) { +input FacetSourceCandidateWhereInput @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) { """ - Millimeter + Logical AND of multiple conditions """ - mm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _and: [FacetSourceCandidateWhereInput!] """ - Centimeter + Logical OR of multiple conditions """ - cm @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _or: [FacetSourceCandidateWhereInput!] """ - Meter + Negate the condition """ - m @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + _not: FacetSourceCandidateWhereInput """ - Inch + Filter by id """ - in @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) + id: IDFilter """ - Foot + Filter by facetType """ - ft @join__enumValue(graph: APPS_ADMIN) @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) @join__enumValue(graph: MEDIA_ADMIN) @join__enumValue(graph: ORDERS_ADMIN) @join__enumValue(graph: PROJECT_ADMIN) -} - -""" -Fields available for sorting Organization -""" -enum OrganizationOrderField @join__type(graph: IAM_ADMIN) { + facetType: StringFilter """ - Sort by name + Filter by handle """ - name @join__enumValue(graph: IAM_ADMIN) + handle: StringFilter """ - Sort by displayName + Filter by name """ - displayName @join__enumValue(graph: IAM_ADMIN) + name: StringFilter """ - Sort by createdAt + Filter by sourceSortBucket """ - createdAt @join__enumValue(graph: IAM_ADMIN) + sourceSortBucket: IntFilter """ - Sort by updatedAt + Filter by sortName """ - updatedAt @join__enumValue(graph: IAM_ADMIN) -} - -""" -Sort direction -""" -enum SortDirection @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) { - asc @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) - desc @join__enumValue(graph: IAM_ADMIN) @join__enumValue(graph: INVENTORY_ADMIN) + sortName: StringFilter } """ -Action level for permissions. -Hierarchy: read < write < admin -- admin includes write and read -- write includes read +Ordering configuration for FacetSourceCandidate """ -enum Action @join__type(graph: IAM_ADMIN) { - read @join__enumValue(graph: IAM_ADMIN) - write @join__enumValue(graph: IAM_ADMIN) - admin @join__enumValue(graph: IAM_ADMIN) +input FacetSourceCandidateOrderByInput @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) { + """ + Field to order by + """ + field: FacetSourceCandidateOrderField! + """ + Sort direction + """ + direction: SortDirection! } """ -Fields available for sorting Warehouse +Filter conditions for FacetValueCandidate """ -enum WarehouseOrderField @join__type(graph: INVENTORY_ADMIN) { +input FacetValueCandidateWhereInput @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) { """ - Sort by id + Logical AND of multiple conditions """ - id @join__enumValue(graph: INVENTORY_ADMIN) + _and: [FacetValueCandidateWhereInput!] """ - Sort by code + Logical OR of multiple conditions """ - code @join__enumValue(graph: INVENTORY_ADMIN) + _or: [FacetValueCandidateWhereInput!] """ - Sort by name + Negate the condition """ - name @join__enumValue(graph: INVENTORY_ADMIN) + _not: FacetValueCandidateWhereInput """ - Sort by isDefault + Filter by id """ - isDefault @join__enumValue(graph: INVENTORY_ADMIN) + id: IDFilter """ - Sort by createdAt + Filter by handle """ - createdAt @join__enumValue(graph: INVENTORY_ADMIN) + handle: StringFilter """ - Sort by updatedAt + Filter by label """ - updatedAt @join__enumValue(graph: INVENTORY_ADMIN) + label: StringFilter } """ -Fields available for sorting WarehouseStock +Ordering configuration for FacetValueCandidate """ -enum WarehouseStockOrderField @join__type(graph: INVENTORY_ADMIN) { - """ - Sort by id - """ - id @join__enumValue(graph: INVENTORY_ADMIN) +input FacetValueCandidateOrderByInput @join__type(graph: CATALOG_ADMIN) @join__type(graph: LISTING_ADMIN) { """ - Sort by warehouseId + Field to order by """ - warehouseId @join__enumValue(graph: INVENTORY_ADMIN) + field: FacetValueCandidateOrderField! """ - Sort by variantId + Sort direction """ - variantId @join__enumValue(graph: INVENTORY_ADMIN) + direction: SortDirection! +} + +input WarehouseAssignableVariantWhereInput @join__type(graph: CATALOG_ADMIN) { """ - Sort by quantityOnHand + Logical AND of multiple conditions """ - quantityOnHand @join__enumValue(graph: INVENTORY_ADMIN) + _and: [WarehouseAssignableVariantWhereInput!] """ - Sort by updatedAt + Logical OR of multiple conditions """ - updatedAt @join__enumValue(graph: INVENTORY_ADMIN) + _or: [WarehouseAssignableVariantWhereInput!] """ - Sort by createdAt + Negate the condition """ - createdAt @join__enumValue(graph: INVENTORY_ADMIN) -} - -""" -Display type for product options in the UI. -""" -enum OptionDisplayType @join__type(graph: INVENTORY_ADMIN) { - DROPDOWN @join__enumValue(graph: INVENTORY_ADMIN) - SWATCH @join__enumValue(graph: INVENTORY_ADMIN) - BUTTONS @join__enumValue(graph: INVENTORY_ADMIN) -} - -""" -Type of visual swatch for option values. -""" -enum SwatchType @join__type(graph: INVENTORY_ADMIN) { - COLOR @join__enumValue(graph: INVENTORY_ADMIN) - GRADIENT @join__enumValue(graph: INVENTORY_ADMIN) - IMAGE @join__enumValue(graph: INVENTORY_ADMIN) -} - -""" -Provider type for files. -""" -enum FileProvider @join__type(graph: MEDIA_ADMIN) { + _not: WarehouseAssignableVariantWhereInput """ - File stored in S3 + Filter by variant ID """ - S3 @join__enumValue(graph: MEDIA_ADMIN) + id: IDFilter """ - YouTube video + Filter by product ID """ - YOUTUBE @join__enumValue(graph: MEDIA_ADMIN) + productId: IDFilter """ - Vimeo video + Filter by product name in the current locale """ - VIMEO @join__enumValue(graph: MEDIA_ADMIN) + productName: StringFilter """ - External URL + Filter by variant SKU """ - URL @join__enumValue(graph: MEDIA_ADMIN) + sku: StringFilter """ - Local file storage + Filter by default variant flag """ - LOCAL @join__enumValue(graph: MEDIA_ADMIN) -} - -enum CountryCode @join__type(graph: ORDERS_ADMIN) { + isDefault: BooleanFilter """ - United States + Filter by variant handle """ - US @join__enumValue(graph: ORDERS_ADMIN) + handle: StringFilter """ - Canada + Filter by external system """ - CA @join__enumValue(graph: ORDERS_ADMIN) + externalSystem: StringFilter """ - Mexico + Filter by external ID """ - MX @join__enumValue(graph: ORDERS_ADMIN) + externalId: StringFilter """ - United Kingdom + Filter by creation date """ - GB @join__enumValue(graph: ORDERS_ADMIN) + createdAt: DateTimeFilter """ - Germany + Filter by update date """ - DE @join__enumValue(graph: ORDERS_ADMIN) + updatedAt: DateTimeFilter +} + +input WarehouseAssignableVariantOrderByInput @join__type(graph: CATALOG_ADMIN) { + field: WarehouseAssignableVariantOrderField! + direction: SortDirection! +} + +""" +Bulk update input - same structure as productUpdate but for multiple products. +Max 100 products, 500 operations total. +""" +input ProductBulkUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - France + List of products to update with their operations. """ - FR @join__enumValue(graph: ORDERS_ADMIN) + products: [ProductBulkUpdateItem!]! +} + +""" +A single product's update within a bulk request. +""" +input ProductBulkUpdateItem @join__type(graph: CATALOG_ADMIN) { """ - Italy + The product ID to update. """ - IT @join__enumValue(graph: ORDERS_ADMIN) + productId: ID! """ - Spain + Expected revision for optimistic locking. If provided, fails if product was modified. """ - ES @join__enumValue(graph: ORDERS_ADMIN) + expectedRevision: Int """ - Australia + Product-level operations. """ - AU @join__enumValue(graph: ORDERS_ADMIN) + operations: ProductUpdateInput +} + +input BundleBundlesMetaInput @join__type(graph: CATALOG_ADMIN) { + categoriesScope: ProductCategoriesScopeInput +} + +input BundleCreateInput @join__type(graph: CATALOG_ADMIN) { """ - New Zealand + Bundle title. """ - NZ @join__enumValue(graph: ORDERS_ADMIN) + title: String! """ - Japan + URL-friendly handle for the bundle. """ - JP @join__enumValue(graph: ORDERS_ADMIN) + handle: String! """ - China + Vendor ID to associate with the bundle. """ - CN @join__enumValue(graph: ORDERS_ADMIN) + vendorId: ID """ - South Korea + Bundle description. """ - KR @join__enumValue(graph: ORDERS_ADMIN) + description: RichTextInput """ - India + Short excerpt in multiple formats. """ - IN @join__enumValue(graph: ORDERS_ADMIN) + excerpt: RichTextInput """ - Brazil + File IDs for bundle media (already uploaded via mediaMutation.fileUpload). """ - BR @join__enumValue(graph: ORDERS_ADMIN) + mediaFileIds: [ID!] """ - Argentina + Bundle options. """ - AR @join__enumValue(graph: ORDERS_ADMIN) + options: [ProductCreateOptionInput!] """ - South Africa + Bundle variants to create. """ - ZA @join__enumValue(graph: ORDERS_ADMIN) + variants: [ProductCreateVariantInput!] """ - Russia + Inventory tracking settings for the bundle. """ - RU @join__enumValue(graph: ORDERS_ADMIN) + inventoryItem: InventoryItemInput """ - Ukraine + High-level bundle type. """ - UA @join__enumValue(graph: ORDERS_ADMIN) + type: BundleType """ - Poland + Configurator display style. """ - PL @join__enumValue(graph: ORDERS_ADMIN) + displayStyle: BundleDisplayStyle +} + +input BundleUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Netherlands + The URL-friendly handle for the bundle. """ - NL @join__enumValue(graph: ORDERS_ADMIN) + handle: String """ - Belgium + Bundle title. """ - BE @join__enumValue(graph: ORDERS_ADMIN) + title: String """ - Sweden + Vendor ID to associate with the bundle. Pass null to clear. """ - SE @join__enumValue(graph: ORDERS_ADMIN) + vendorId: ID """ - Norway + Bundle content (description, excerpt). """ - NO @join__enumValue(graph: ORDERS_ADMIN) + content: ProductContentInput """ - Denmark + SEO and Open Graph metadata. """ - DK @join__enumValue(graph: ORDERS_ADMIN) + seo: ProductSeoInput """ - Finland + Bundle status: DRAFT or PUBLISHED. """ - FI @join__enumValue(graph: ORDERS_ADMIN) + status: ProductStatus """ - Switzerland + Bundle media. """ - CH @join__enumValue(graph: ORDERS_ADMIN) + media: ProductMediaInput """ - Austria + Bundle category assignment operations. """ - AT @join__enumValue(graph: ORDERS_ADMIN) + categories: [ProductCategoryOperationInput!] """ - Portugal + Bundle tag assignment operations. """ - PT @join__enumValue(graph: ORDERS_ADMIN) + tags: [ProductTagOperationInput!] """ - Ireland + Variant create, update, and delete operations. """ - IE @join__enumValue(graph: ORDERS_ADMIN) + variants: [VariantOperationInput!] """ - Czech Republic + High-level bundle type. """ - CZ @join__enumValue(graph: ORDERS_ADMIN) + type: BundleType """ - Hungary + Configurator display style. """ - HU @join__enumValue(graph: ORDERS_ADMIN) + displayStyle: BundleDisplayStyle +} + +input BundleConfigurationCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Slovakia + Product global ID of the bundle. """ - SK @join__enumValue(graph: ORDERS_ADMIN) + bundleId: ID! """ - Slovenia + Expected parent bundle product revision. Required for optimistic locking. """ - SI @join__enumValue(graph: ORDERS_ADMIN) + expectedRevision: Int! """ - Croatia + Configuration name. """ - HR @join__enumValue(graph: ORDERS_ADMIN) + name: String! +} + +input BundleConfigurationUpdateInput @join__type(graph: CATALOG_ADMIN) { + id: ID! + expectedRevision: Int! + name: String +} + +input BundleConfigurationDeleteInput @join__type(graph: CATALOG_ADMIN) { + id: ID! + expectedRevision: Int! +} + +input BundleGroupsSyncInput @join__type(graph: CATALOG_ADMIN) { + configurationId: ID! + expectedRevision: Int! """ - Greece + Complete list of groups for this configuration. + Groups not present in this list are deleted. """ - GR @join__enumValue(graph: ORDERS_ADMIN) + groups: [BundleGroupSyncItemInput!]! +} + +input BundleGroupSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Turkey + Existing group ID. Null creates a new group. + Existing groups in this configuration but missing from BundleGroupsSyncInput.groups are deleted. """ - TR @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Romania + Localized title for current locale. """ - RO @join__enumValue(graph: ORDERS_ADMIN) + title: String! + minSelection: Int + maxSelection: Int """ - Bulgaria + Sort order within the configuration. """ - BG @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! """ - Estonia + Complete list of items inside this group. """ - EE @join__enumValue(graph: ORDERS_ADMIN) + items: [BundleItemSyncItemInput!]! +} + +input BundleItemSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Latvia + Existing item ID. Null creates a new item. + Existing items in this group but missing from BundleGroupSyncItemInput.items are deleted. """ - LV @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Lithuania + Whether the item references a product or a concrete variant. """ - LT @join__enumValue(graph: ORDERS_ADMIN) + itemType: BundleItemType! """ - Iceland + Referenced product ID for PRODUCT items. """ - IS @join__enumValue(graph: ORDERS_ADMIN) + refProductId: ID """ - Luxembourg + Referenced variant ID for VARIANT items. """ - LU @join__enumValue(graph: ORDERS_ADMIN) + refVariantId: ID """ - Liechtenstein + Featured image override. """ - LI @join__enumValue(graph: ORDERS_ADMIN) + featuredImageId: ID """ - Malta + Minimum selectable quantity. """ - MT @join__enumValue(graph: ORDERS_ADMIN) + minQty: Int """ - Cyprus + Maximum selectable quantity. """ - CY @join__enumValue(graph: ORDERS_ADMIN) + maxQty: Int """ - Israel + Default quantity. """ - IL @join__enumValue(graph: ORDERS_ADMIN) + defaultQty: Int """ - Saudi Arabia + Inline price rule. Cannot be used together with pricingTemplateId. """ - SA @join__enumValue(graph: ORDERS_ADMIN) + priceRule: BundlePriceRuleInput """ - United Arab Emirates + Reusable pricing template ID. Cannot be used together with priceRule. """ - AE @join__enumValue(graph: ORDERS_ADMIN) + pricingTemplateId: ID """ - Qatar + Allowed option/value selections for PRODUCT items. """ - QA @join__enumValue(graph: ORDERS_ADMIN) + optionSelections: [BundleItemOptionSelectionSyncItemInput!] """ - Kuwait + Optional localized title override for current locale. """ - KW @join__enumValue(graph: ORDERS_ADMIN) + title: String """ - Oman + Whether item is visible in the configurator. """ - OM @join__enumValue(graph: ORDERS_ADMIN) + visible: Boolean! """ - Jordan + Whether item is selected by default. """ - JO @join__enumValue(graph: ORDERS_ADMIN) + selected: Boolean! """ - Egypt + Sort order within the group. """ - EG @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! +} + +input BundleItemOptionSelectionSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Morocco + Existing option selection ID. Null creates a new option selection. """ - MA @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Tunisia + Referenced product option ID. """ - TN @join__enumValue(graph: ORDERS_ADMIN) + optionId: ID! """ - Algeria + Parent option ID for dependent option trees. """ - DZ @join__enumValue(graph: ORDERS_ADMIN) + parentOptionId: ID """ - Nigeria + Sort order within option selections. """ - NG @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! """ - Kenya + Complete list of option value selections. """ - KE @join__enumValue(graph: ORDERS_ADMIN) + values: [BundleItemOptionValueSelectionSyncItemInput!]! +} + +input BundleItemOptionValueSelectionSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Ethiopia + Existing value selection ID. Null creates a new value selection. """ - ET @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Ghana + Referenced product option value ID. """ - GH @join__enumValue(graph: ORDERS_ADMIN) + optionValueId: ID """ - Senegal + Stable value copy for displaying stale/unavailable values. """ - SN @join__enumValue(graph: ORDERS_ADMIN) + value: String! """ - Ivory Coast + Selection status. """ - CI @join__enumValue(graph: ORDERS_ADMIN) + status: BundleItemOptionValueSelectionStatus! """ - Tanzania + Sort order within option values. """ - TZ @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! +} + +input BundlePriceRuleInput @join__type(graph: CATALOG_ADMIN) { """ - Uganda + Existing price rule ID. Null creates a new price rule. """ - UG @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Cameroon + Pricing strategy. """ - CM @join__enumValue(graph: ORDERS_ADMIN) + priceType: BundlePriceType! """ - Zambia + Money values for FIXED and DISCOUNT_FIXED rules. """ - ZM @join__enumValue(graph: ORDERS_ADMIN) + amounts: [BundlePriceRuleAmountInput!] """ - Zimbabwe + Percent value for DISCOUNT_PERCENT rules. """ - ZW @join__enumValue(graph: ORDERS_ADMIN) + percent: BundlePriceRulePercentInput +} + +input BundlePriceRuleAmountInput @join__type(graph: CATALOG_ADMIN) { """ - Mozambique + The currency code. """ - MZ @join__enumValue(graph: ORDERS_ADMIN) + currency: CurrencyCode! """ - Botswana + Amount in minor units. """ - BW @join__enumValue(graph: ORDERS_ADMIN) + amountMinor: BigInt! +} + +input BundlePriceRulePercentInput @join__type(graph: CATALOG_ADMIN) { """ - Namibia + Percent value, 0..100. """ - NA @join__enumValue(graph: ORDERS_ADMIN) + value: Int! +} + +input BundlePricingTemplatesSyncInput @join__type(graph: CATALOG_ADMIN) { + configurationId: ID! + expectedRevision: Int! """ - Angola + Complete list of pricing templates for this configuration. + Templates not present in this list are deleted. """ - AO @join__enumValue(graph: ORDERS_ADMIN) + pricingTemplates: [BundlePricingTemplateSyncItemInput!]! +} + +input BundlePricingTemplateSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Democratic Republic of the Congo + Existing pricing template ID. Null creates a new template. + Existing templates in this configuration but missing from + BundlePricingTemplatesSyncInput.pricingTemplates are deleted. """ - CD @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Sudan + Template name. """ - SD @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - Pakistan + Reusable price rule. """ - PK @join__enumValue(graph: ORDERS_ADMIN) + priceRule: BundlePriceRuleInput! """ - Bangladesh + Sort order within configuration. """ - BD @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! +} + +input BundleDependencyRulesSyncInput @join__type(graph: CATALOG_ADMIN) { + configurationId: ID! + expectedRevision: Int! """ - Nepal + Complete list of dependency rules for this configuration. + Rules not present in this list are deleted. """ - NP @join__enumValue(graph: ORDERS_ADMIN) + dependencyRules: [BundleDependencyRuleSyncItemInput!]! +} + +input BundleDependencyRuleSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Thailand + Existing dependency rule ID. Null creates a new rule. + Existing rules in this configuration but missing from + BundleDependencyRulesSyncInput.dependencyRules are deleted. """ - TH @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Vietnam + Rule name. """ - VN @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - Malaysia + Whether the rule is enabled. """ - MY @join__enumValue(graph: ORDERS_ADMIN) + enabled: Boolean! """ - Singapore + Rule priority. """ - SG @join__enumValue(graph: ORDERS_ADMIN) + priority: Int! """ - Indonesia + How condition groups are combined. """ - ID @join__enumValue(graph: ORDERS_ADMIN) + logicOperator: BundleLogicOperator! """ - Philippines + Complete list of condition groups. """ - PH @join__enumValue(graph: ORDERS_ADMIN) + conditionGroups: [BundleConditionGroupSyncItemInput!]! """ - Myanmar + Complete list of actions. """ - MM @join__enumValue(graph: ORDERS_ADMIN) + actions: [BundleDependencyActionSyncItemInput!]! +} + +input BundleConditionGroupSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Cambodia + Existing condition group ID. Null creates a new group. """ - KH @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Laos + How conditions are combined. """ - LA @join__enumValue(graph: ORDERS_ADMIN) + logicOperator: BundleLogicOperator! """ - Brunei + Sort order within the rule. """ - BN @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! """ - Kazakhstan + Complete list of conditions. """ - KZ @join__enumValue(graph: ORDERS_ADMIN) + conditions: [BundleConditionSyncItemInput!]! +} + +input BundleConditionSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Uzbekistan + Existing condition ID. Null creates a new condition. """ - UZ @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Turkmenistan + Condition category. """ - TM @join__enumValue(graph: ORDERS_ADMIN) + category: BundleConditionCategory! """ - Kyrgyzstan + Condition subject. """ - KG @join__enumValue(graph: ORDERS_ADMIN) + subject: BundleConditionSubject! """ - Tajikistan + Condition operator. """ - TJ @join__enumValue(graph: ORDERS_ADMIN) + operator: BundleConditionOperator! """ - Georgia + Target type. """ - GE @join__enumValue(graph: ORDERS_ADMIN) + targetType: BundleDependencyTargetType! """ - Armenia + Target ID. Points to an item, group, or the parent bundle product. """ - AM @join__enumValue(graph: ORDERS_ADMIN) + targetId: ID! """ - Azerbaijan + Numeric value for numeric conditions. """ - AZ @join__enumValue(graph: ORDERS_ADMIN) + value: Int """ - Belarus + Sort order within the condition group. """ - BY @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! +} + +input BundleDependencyActionSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Moldova + Existing action ID. Null creates a new action. """ - MD @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Serbia + Action type. """ - RS @join__enumValue(graph: ORDERS_ADMIN) + actionType: BundleDependencyActionType! """ - Montenegro + Target type. """ - ME @join__enumValue(graph: ORDERS_ADMIN) + targetType: BundleDependencyTargetType! """ - North Macedonia + Target ID. Null is allowed when targetType is BUNDLE. """ - MK @join__enumValue(graph: ORDERS_ADMIN) + targetId: ID """ - Bosnia and Herzegovina + Required value for SET_REQUIRED. """ - BA @join__enumValue(graph: ORDERS_ADMIN) + requiredValue: Boolean """ - Albania + Price rule for ADJUST_PRICE. """ - AL @join__enumValue(graph: ORDERS_ADMIN) + priceRule: BundlePriceRuleInput """ - Kosovo + Whether this action can stack with other matching actions. """ - XK @join__enumValue(graph: ORDERS_ADMIN) + stackable: Boolean! """ - Greenland + Sort order within the rule. """ - GL @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int! +} + +input CategoryHierarchyScopeInput @join__type(graph: CATALOG_ADMIN) { + referenceId: ID! + direction: CategoryHierarchyScopeDirection! + includeReference: Boolean = false + mode: CategoryHierarchyScopeMode! +} + +input CategoryCategoriesMetaInput @join__type(graph: CATALOG_ADMIN) { + hierarchyScope: CategoryHierarchyScopeInput + productsScope: CategoryProductsScopeInput +} + +input CategoryProductsScopeInput @join__type(graph: CATALOG_ADMIN) { + referenceIds: [ID!]! + mode: CategoryHierarchyScopeMode! +} + +""" +Input for creating a category. +""" +input CategoryCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Panama + The URL-friendly handle for the category. """ - PA @join__enumValue(graph: ORDERS_ADMIN) + handle: String! """ - Costa Rica + The display name of the category. """ - CR @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - El Salvador + Optional parent category ID. """ - SV @join__enumValue(graph: ORDERS_ADMIN) + parentId: ID """ - Guatemala + Optional description. """ - GT @join__enumValue(graph: ORDERS_ADMIN) + description: RichTextInput """ - Honduras + Optional short excerpt. """ - HN @join__enumValue(graph: ORDERS_ADMIN) + excerpt: RichTextInput """ - Nicaragua + SEO metadata. """ - NI @join__enumValue(graph: ORDERS_ADMIN) + seo: SeoInput """ - Jamaica + File IDs for category media. """ - JM @join__enumValue(graph: ORDERS_ADMIN) + mediaFileIds: [ID!] """ - Cuba + Whether to publish immediately. """ - CU @join__enumValue(graph: ORDERS_ADMIN) + publish: Boolean +} + +input CategoryContentInput @join__type(graph: CATALOG_ADMIN) { """ - Dominican Republic + The category description. """ - DO @join__enumValue(graph: ORDERS_ADMIN) + description: RichTextInput """ - Haiti + The short category excerpt. """ - HT @join__enumValue(graph: ORDERS_ADMIN) + excerpt: RichTextInput +} + +input CategoryMediaInput @join__type(graph: CATALOG_ADMIN) { """ - Trinidad and Tobago + File IDs for category media. """ - TT @join__enumValue(graph: ORDERS_ADMIN) + fileIds: [ID!]! +} + +input CategoryHierarchyInput @join__type(graph: CATALOG_ADMIN) { """ - Barbados + The new parent category ID, or null for root. """ - BB @join__enumValue(graph: ORDERS_ADMIN) + parentId: ID +} + +input CategorySortInput @join__type(graph: CATALOG_ADMIN) { """ - Bahamas + Default product sort for this category PLP. """ - BS @join__enumValue(graph: ORDERS_ADMIN) + defaultSort: ProductSortBy! """ - Paraguay + Default sort direction for this category PLP. """ - PY @join__enumValue(graph: ORDERS_ADMIN) + defaultSortDirection: SortDirection! +} + +""" +Input for updating a category through section-based operations. +""" +input CategoryUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Uruguay + The URL-friendly handle for the category. """ - UY @join__enumValue(graph: ORDERS_ADMIN) + handle: String """ - Chile + The display name of the category. """ - CL @join__enumValue(graph: ORDERS_ADMIN) + name: String """ - Peru + Translated content. """ - PE @join__enumValue(graph: ORDERS_ADMIN) + content: CategoryContentInput """ - Colombia + SEO metadata. """ - CO @join__enumValue(graph: ORDERS_ADMIN) + seo: SeoInput """ - Venezuela + Category status. """ - VE @join__enumValue(graph: ORDERS_ADMIN) + status: CategoryStatus """ - Bolivia + Category media replacement. """ - BO @join__enumValue(graph: ORDERS_ADMIN) + media: CategoryMediaInput """ - Ecuador + Hierarchy move. """ - EC @join__enumValue(graph: ORDERS_ADMIN) + hierarchy: CategoryHierarchyInput """ - Suriname + PLP sort preferences. """ - SR @join__enumValue(graph: ORDERS_ADMIN) + sort: CategorySortInput +} + +""" +Input for moving a category in the hierarchy. +""" +input CategoryMoveInput @join__type(graph: CATALOG_ADMIN) { """ - Guyana + The ID of the category to move. """ - GY @join__enumValue(graph: ORDERS_ADMIN) + id: ID! """ - Fiji + The new parent category ID, or null for root. """ - FJ @join__enumValue(graph: ORDERS_ADMIN) + newParentId: ID +} + +""" +Input for deleting a category. +""" +input CategoryDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Papua New Guinea + The ID of the category to delete. """ - PG @join__enumValue(graph: ORDERS_ADMIN) + id: ID! """ - Samoa + Whether to permanently delete (hard delete). """ - WS @join__enumValue(graph: ORDERS_ADMIN) + permanent: Boolean +} + +input CategoryRebalanceInput @join__type(graph: CATALOG_ADMIN) { + categoryId: ID! +} + +input CollectionRuleInput @join__type(graph: CATALOG_ADMIN) { + field: String! + operator: String! + value: JSON! +} + +input CollectionCreateInput @join__type(graph: CATALOG_ADMIN) { + handle: String + type: CollectionType! + name: String! + description: RichTextInput + excerpt: RichTextInput + media: [CollectionMediaInput!] + seo: SeoInput + defaultSort: ProductSortBy + defaultSortDirection: SortDirection + activeFrom: DateTime + activeTo: DateTime + publish: Boolean +} + +input CollectionUpdateInput @join__type(graph: CATALOG_ADMIN) { + id: ID! + handle: String + name: String + description: RichTextInput + excerpt: RichTextInput + media: [CollectionMediaInput!] + seo: SeoInput + defaultSort: ProductSortBy + defaultSortDirection: SortDirection + activeFrom: DateTime + activeTo: DateTime + publish: Boolean +} + +input CollectionMediaInput @join__type(graph: CATALOG_ADMIN) { + fileId: ID! + sortIndex: Int +} + +input CollectionDeleteInput @join__type(graph: CATALOG_ADMIN) { + id: ID! +} + +input CollectionAddProductsInput @join__type(graph: CATALOG_ADMIN) { + collectionId: ID! + productIds: [ID!]! +} + +input CollectionRemoveProductsInput @join__type(graph: CATALOG_ADMIN) { + collectionId: ID! + productIds: [ID!]! +} + +input CollectionMoveProductInput @join__type(graph: CATALOG_ADMIN) { + collectionId: ID! + productId: ID! + afterProductId: ID + beforeProductId: ID +} + +input CollectionUpdateRulesInput @join__type(graph: CATALOG_ADMIN) { + collectionId: ID! + rules: [CollectionRuleInput!]! +} + +""" +Sync all product features in a single transaction. +""" +input ProductFeaturesSyncInput @join__type(graph: CATALOG_ADMIN) { """ - Tonga + The ID of the product. """ - TO @join__enumValue(graph: ORDERS_ADMIN) + productId: ID! """ - Solomon Islands + Complete list of features (replaces all existing features). """ - SB @join__enumValue(graph: ORDERS_ADMIN) + features: [ProductFeatureSyncItemInput!]! +} + +input ProductFeatureSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Vanuatu + Database ID. Null for new records. + - If provided: update existing feature + - If null/omitted: create new feature (backend generates ID) + Features in DB but not in this list will be DELETED. """ - VU @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - New Caledonia + Tree position as integer array. + - [0], [1], [2] for root items + - [0, 0], [0, 1], [1, 0] for children + Parent is derived: parent of [0, 1] is [0]. + Groups must have length 1 (root only). """ - NC @join__enumValue(graph: ORDERS_ADMIN) + index: [Int!]! """ - Marshall Islands + The URL-friendly slug for this feature. """ - MH @join__enumValue(graph: ORDERS_ADMIN) + slug: String! """ - Micronesia + Whether this is a group (true) or attribute (false). """ - FM @join__enumValue(graph: ORDERS_ADMIN) + isGroup: Boolean! """ - Palau + Display name. """ - PW @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - Maldives + Values for this feature (only when isGroup = false). """ - MV @join__enumValue(graph: ORDERS_ADMIN) + values: [ProductFeatureValueSyncInput!] +} + +input ProductFeatureValueSyncInput @join__type(graph: CATALOG_ADMIN) { """ - Seychelles + Database ID. Null for new records. """ - SC @join__enumValue(graph: ORDERS_ADMIN) + id: ID """ - Mauritius + Position within the feature's values (0, 1, 2, ...). """ - MU @join__enumValue(graph: ORDERS_ADMIN) + index: Int! """ - Sri Lanka + The URL-friendly slug for this feature value. """ - LK @join__enumValue(graph: ORDERS_ADMIN) + slug: String! """ - Bhutan + Display name. """ - BT @join__enumValue(graph: ORDERS_ADMIN) + name: String! +} + +""" +Input for creating a feature value. +""" +input ProductFeatureValueCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Mongolia + Display name. """ - MN @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - North Korea + The URL-friendly slug for this feature value. """ - KP @join__enumValue(graph: ORDERS_ADMIN) + slug: String! +} + +""" +Input for creating a feature on a product. +""" +input ProductFeatureCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Iraq + The ID of the product. """ - IQ @join__enumValue(graph: ORDERS_ADMIN) + productId: ID! """ - Iran + The URL-friendly slug for the feature. """ - IR @join__enumValue(graph: ORDERS_ADMIN) + slug: String! """ - Afghanistan + Display name. """ - AF @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - Yemen + The values for this feature. """ - YE @join__enumValue(graph: ORDERS_ADMIN) + values: [ProductFeatureValueCreateInput!]! +} + +""" +Input for updating an existing feature value. +""" +input ProductFeatureValueUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Syria + The ID of the value to update. """ - SY @join__enumValue(graph: ORDERS_ADMIN) + id: ID! """ - Lebanon + Display name. """ - LB @join__enumValue(graph: ORDERS_ADMIN) + name: String """ - Palestine + The URL-friendly slug for this value. """ - PS @join__enumValue(graph: ORDERS_ADMIN) + slug: String +} + +""" +Input for nested value operations in feature update. +""" +input ProductFeatureValuesInput @join__type(graph: CATALOG_ADMIN) { """ - Bahrain + Values to create. """ - BH @join__enumValue(graph: ORDERS_ADMIN) + create: [ProductFeatureValueCreateInput!] """ - Malawi + Values to update. """ - MW @join__enumValue(graph: ORDERS_ADMIN) + update: [ProductFeatureValueUpdateInput!] """ - Rwanda + IDs of values to delete. """ - RW @join__enumValue(graph: ORDERS_ADMIN) + delete: [ID!] +} + +""" +Input for updating a feature. +""" +input ProductFeatureUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Burundi + The ID of the feature to update. """ - BI @join__enumValue(graph: ORDERS_ADMIN) + id: ID! """ - South Sudan + The URL-friendly slug for the feature. """ - SS @join__enumValue(graph: ORDERS_ADMIN) + slug: String """ - Lesotho + Display name. """ - LS @join__enumValue(graph: ORDERS_ADMIN) + name: String """ - Swaziland (Eswatini) + Nested value operations. """ - SZ @join__enumValue(graph: ORDERS_ADMIN) + values: ProductFeatureValuesInput +} + +""" +Input for deleting a feature from a product. +""" +input ProductFeatureDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Madagascar + The ID of the feature to delete. """ - MG @join__enumValue(graph: ORDERS_ADMIN) + id: ID! +} + +input InventoryItemWhereInput @join__type(graph: CATALOG_ADMIN) { """ - Central African Republic + Logical AND of multiple conditions """ - CF @join__enumValue(graph: ORDERS_ADMIN) + _and: [InventoryItemWhereInput!] """ - Republic of the Congo + Logical OR of multiple conditions """ - CG @join__enumValue(graph: ORDERS_ADMIN) + _or: [InventoryItemWhereInput!] """ - Gabon + Negate the condition """ - GA @join__enumValue(graph: ORDERS_ADMIN) + _not: InventoryItemWhereInput """ - Guinea + Filter by inventory item ID """ - GN @join__enumValue(graph: ORDERS_ADMIN) + id: IDFilter """ - Guinea-Bissau + Filter by variant ID """ - GW @join__enumValue(graph: ORDERS_ADMIN) + variantId: IDFilter """ - Equatorial Guinea + Filter by product ID """ - GQ @join__enumValue(graph: ORDERS_ADMIN) + productId: IDFilter """ - Sierra Leone + Filter by product name in the current locale """ - SL @join__enumValue(graph: ORDERS_ADMIN) + productName: StringFilter """ - Liberia + Filter by SKU """ - LR @join__enumValue(graph: ORDERS_ADMIN) + sku: StringFilter """ - Benin + Filter by trackInventory """ - BJ @join__enumValue(graph: ORDERS_ADMIN) + trackInventory: BooleanFilter """ - Togo + Filter by quantity on hand in the selected warehouse scope """ - TG @join__enumValue(graph: ORDERS_ADMIN) + quantityOnHand: IntFilter """ - Niger + Filter by reserved quantity in the selected warehouse scope """ - NE @join__enumValue(graph: ORDERS_ADMIN) + reservedQuantity: IntFilter """ - Mali + Filter by unavailable quantity in the selected warehouse scope """ - ML @join__enumValue(graph: ORDERS_ADMIN) + unavailableQuantity: IntFilter """ - Burkina Faso + Filter by available for sale quantity in the selected warehouse scope """ - BF @join__enumValue(graph: ORDERS_ADMIN) + availableForSale: IntFilter +} + +input InventoryItemOrderByInput @join__type(graph: CATALOG_ADMIN) { + field: InventoryItemOrderField! + direction: SortDirection! +} + +input InventoryItemInventoryItemsMetaInput @join__type(graph: CATALOG_ADMIN) { + warehouseScope: InventoryItemWarehouseScopeInput +} + +input InventoryItemWarehouseScopeInput @join__type(graph: CATALOG_ADMIN) { + referenceIds: [ID!]! + mode: InventoryItemWarehouseScopeMode! +} + +input InventoryItemUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Chad + The inventory item ID to update """ - TD @join__enumValue(graph: ORDERS_ADMIN) + id: ID! """ - Mauritania + New SKU value """ - MR @join__enumValue(graph: ORDERS_ADMIN) + sku: String """ - Gambia + Whether to track inventory """ - GM @join__enumValue(graph: ORDERS_ADMIN) + trackInventory: Boolean """ - Cape Verde + Whether to continue selling when out of stock """ - CV @join__enumValue(graph: ORDERS_ADMIN) + continueSellingWhenOutOfStock: Boolean """ - Eritrea + Stock update for a specific warehouse """ - ER @join__enumValue(graph: ORDERS_ADMIN) + stock: InventoryItemStockInput """ - Djibouti + Unit cost update """ - DJ @join__enumValue(graph: ORDERS_ADMIN) + unitCost: InventoryItemCostInput +} + +input InventoryItemStockInput @join__type(graph: CATALOG_ADMIN) { + warehouseId: ID! + onHand: Int! + unavailable: Int +} + +input InventoryItemCostInput @join__type(graph: CATALOG_ADMIN) { + currency: String! + amountMinor: BigInt! +} + +""" +Input for updating variant media (replaces all existing media). +""" +input VariantUpdateMediaInput @join__type(graph: CATALOG_ADMIN) { """ - Comoros + The variant ID. """ - KM @join__enumValue(graph: ORDERS_ADMIN) + variantId: ID! """ - Andorra + File IDs in desired order (first = primary). Empty array clears all media. """ - AD @join__enumValue(graph: ORDERS_ADMIN) + fileIds: [ID!]! +} + +""" +Input for selecting an option value for a variant. +""" +input SelectedOptionInput @join__type(graph: CATALOG_ADMIN) { """ - Monaco + The ID of the option. """ - MC @join__enumValue(graph: ORDERS_ADMIN) + optionId: ID! """ - San Marino + The ID of the option value. """ - SM @join__enumValue(graph: ORDERS_ADMIN) + optionValueId: ID! +} + +""" +Input for creating/updating a swatch. +""" +input ProductOptionSwatchInput @join__type(graph: CATALOG_ADMIN) { """ - Vatican City + The type of swatch. """ - VA @join__enumValue(graph: ORDERS_ADMIN) + swatchType: SwatchType! """ - Timor-Leste (East Timor) + The primary color (hex code or color name). """ - TL @join__enumValue(graph: ORDERS_ADMIN) + colorOne: String """ - Antigua and Barbuda + The secondary color for gradients. """ - AG @join__enumValue(graph: ORDERS_ADMIN) + colorTwo: String """ - Saint Kitts and Nevis + The file ID for image-based swatches. """ - KN @join__enumValue(graph: ORDERS_ADMIN) + fileId: ID """ - Saint Lucia + Additional metadata. """ - LC @join__enumValue(graph: ORDERS_ADMIN) + metadata: JSON +} + +""" +Input for creating an option value. +""" +input ProductOptionValueCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Saint Vincent and the Grenadines + The URL-friendly slug for the value. """ - VC @join__enumValue(graph: ORDERS_ADMIN) + slug: String! """ - Grenada + Display name. """ - GD @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - Dominica + The swatch for this value. """ - DM @join__enumValue(graph: ORDERS_ADMIN) + swatch: ProductOptionSwatchInput """ - Belize + Sort order within the option values list. """ - BZ @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int +} + +""" +Input for creating an option on a product. +""" +input ProductOptionCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Aruba + The ID of the product (optional when creating with product). """ - AW @join__enumValue(graph: ORDERS_ADMIN) + productId: ID """ - Curaçao + The URL-friendly slug for the option. """ - CW @join__enumValue(graph: ORDERS_ADMIN) + slug: String! """ - Bermuda + Display name. """ - BM @join__enumValue(graph: ORDERS_ADMIN) + name: String! """ - Faroe Islands + The display type for UI rendering. """ - FO @join__enumValue(graph: ORDERS_ADMIN) + displayType: OptionDisplayType! """ - Isle of Man + Sort order within the product options list. """ - IM @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int """ - Jersey + The values for this option. """ - JE @join__enumValue(graph: ORDERS_ADMIN) + values: [ProductOptionValueCreateInput!]! +} + +""" +Input for updating an existing option value. +""" +input ProductOptionValueUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Guernsey + The ID of the value to update. """ - GG @join__enumValue(graph: ORDERS_ADMIN) + id: ID! """ - Åland Islands + The new slug for the value. """ - AX @join__enumValue(graph: ORDERS_ADMIN) + slug: String """ - Western Sahara + Display name. """ - EH @join__enumValue(graph: ORDERS_ADMIN) + name: String """ - British Virgin Islands + The swatch for this value. """ - VG @join__enumValue(graph: ORDERS_ADMIN) + swatch: ProductOptionSwatchInput """ - US Virgin Islands + Sort order within the option values list. """ - VI @join__enumValue(graph: ORDERS_ADMIN) -} - -enum OrderStatus @join__type(graph: ORDERS_ADMIN) { - DRAFT @join__enumValue(graph: ORDERS_ADMIN) - ACTIVE @join__enumValue(graph: ORDERS_ADMIN) - CLOSED @join__enumValue(graph: ORDERS_ADMIN) - CANCELLED @join__enumValue(graph: ORDERS_ADMIN) -} - -enum OrderCancelReason @join__type(graph: ORDERS_ADMIN) { - CUSTOMER @join__enumValue(graph: ORDERS_ADMIN) - FRAUD @join__enumValue(graph: ORDERS_ADMIN) - INVENTORY @join__enumValue(graph: ORDERS_ADMIN) - STAFF @join__enumValue(graph: ORDERS_ADMIN) - OTHER @join__enumValue(graph: ORDERS_ADMIN) -} - -enum OrderEventType @join__type(graph: ORDERS_ADMIN) { - ORDER_CREATED @join__enumValue(graph: ORDERS_ADMIN) + sortIndex: Int } """ -Status of a store +Input for nested value operations in option update. """ -enum StoreStatus @join__type(graph: PROJECT_ADMIN) { +input ProductOptionValuesInput @join__type(graph: CATALOG_ADMIN) { """ - Store is active and operational + Values to create. """ - ACTIVE @join__enumValue(graph: PROJECT_ADMIN) + create: [ProductOptionValueCreateInput!] """ - Store is inactive and not processing requests + Values to update. """ - INACTIVE @join__enumValue(graph: PROJECT_ADMIN) + update: [ProductOptionValueUpdateInput!] + """ + IDs of values to delete. + """ + delete: [ID!] } """ -Filter conditions for Organization +Input for updating an option. """ -input OrganizationWhereInput @join__type(graph: IAM_ADMIN) { - """ - Logical AND of multiple conditions - """ - _and: [OrganizationWhereInput!] - """ - Logical OR of multiple conditions - """ - _or: [OrganizationWhereInput!] +input ProductOptionUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Negate the condition + The ID of the option to update. """ - _not: OrganizationWhereInput + id: ID! """ - Filter by id + The new slug for the option. """ - id: IDFilter + slug: String """ - Filter by name + Display name. """ - name: StringFilter + name: String """ - Filter by displayName + The new display type. """ - displayName: StringFilter + displayType: OptionDisplayType """ - Filter by createdAt + Sort order within the product options list. """ - createdAt: DateTimeFilter + sortIndex: Int """ - Filter by updatedAt + Nested value operations. """ - updatedAt: DateTimeFilter + values: ProductOptionValuesInput } """ -Ordering configuration for Organization +Input for deleting an option from a product. """ -input OrganizationOrderByInput @join__type(graph: IAM_ADMIN) { +input ProductOptionDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Field to order by - """ - field: OrganizationOrderField! - """ - Sort direction + The ID of the option to delete. """ - direction: SortDirection! + id: ID! } """ -Input for creating an organization. +Input for syncing a single option value. """ -input OrganizationCreateInput @join__type(graph: IAM_ADMIN) { +input ProductOptionValueSyncInput @join__type(graph: CATALOG_ADMIN) { """ - URL-friendly unique identifier. + Existing value ID (null = create new). """ - name: String! + id: ID + """ + Sort order within the option values list. + """ + sortIndex: Int! + """ + The URL-friendly slug for the value. + """ + slug: String! """ Display name. """ - displayName: String! + name: String! + """ + The swatch for this value (null to remove). + """ + swatch: ProductOptionSwatchInput } """ -Role assignment - assigns role to user in specific domain. +Input for syncing a single option. """ -input RoleAssignment @join__type(graph: IAM_ADMIN) { +input ProductOptionSyncItemInput @join__type(graph: CATALOG_ADMIN) { """ - Domain ID ("org" for organization, or "store:{uuid}"). + Existing option ID (null = create new). """ - domain: String! + id: ID """ - Role name. + Sort order within the product options list. """ - role: String! + sortIndex: Int! + """ + The URL-friendly slug for the option. + """ + slug: String! + """ + Display name. + """ + name: String! + """ + The display type for UI rendering. + """ + displayType: OptionDisplayType! + """ + The values for this option. + """ + values: [ProductOptionValueSyncInput!]! } """ -Input for inviting a member to organization. +Input for syncing all product options. """ -input MemberInviteInput @join__type(graph: IAM_ADMIN) { +input ProductOptionsSyncInput @join__type(graph: CATALOG_ADMIN) { """ - Organization ID to invite the member to. - """ - organizationId: ID! - """ - Email address of the user to invite. + The product to sync options for. """ - email: Email! + productId: ID! """ - Role assignments (at least one required). + Complete list of options (replaces existing). """ - roles: [RoleAssignment!]! + options: [ProductOptionSyncItemInput!]! } """ -Input for changing member's role. +Input for setting dimensions (in millimeters). """ -input MemberRoleChangeInput @join__type(graph: IAM_ADMIN) { +input DimensionsInput @join__type(graph: CATALOG_ADMIN) { """ - Organization ID where the member belongs. + Width in millimeters. """ - organizationId: ID! + width: Int! """ - User ID. + Length in millimeters. """ - userId: ID! + length: Int! """ - Domain ("org" for organization, or "store:{uuid}"). + Height in millimeters. """ - domain: String! + height: Int! +} + +""" +Input for setting weight (in grams). +""" +input WeightInput @join__type(graph: CATALOG_ADMIN) { """ - New role name. + Weight in grams. """ - role: String! + value: Int! } """ -Input for removing member's access. +Input for pricing widget query. """ -input MemberAccessRemoveInput @join__type(graph: IAM_ADMIN) { +input PricingWidgetInput @join__type(graph: CATALOG_ADMIN) { """ - Organization ID where the member belongs. + The variant ID to get pricing data for. """ - organizationId: ID! + variantId: ID! """ - User ID. + Currency code to filter by. """ - userId: ID! + currency: CurrencyCode! """ - Domain to remove access from. + Start of the period (optional, defaults to 30 days ago). """ - domain: String! -} - -""" -Input for updating organization. -""" -input OrganizationUpdateInput @join__type(graph: IAM_ADMIN) { + from: DateTime """ - Organization ID. + End of the period (optional, defaults to now). """ - id: ID! + to: DateTime """ - New name (URL-friendly identifier). + Pagination: first N items. """ - name: String + first: Int """ - New display name. + Pagination: cursor after. """ - displayName: String + after: String } """ -Input for transferring organization ownership. +Input for updating a price on a variant. """ -input OwnershipTransferInput @join__type(graph: IAM_ADMIN) { +input VariantUpdatePricingInput @join__type(graph: CATALOG_ADMIN) { """ - Organization ID. + The ID of the variant. """ - organizationId: ID! + variantId: ID! """ - User ID of the new owner. Must be an admin of the organization. + The currency code. """ - newOwnerId: ID! -} - -""" -Input for removing a member from organization. -""" -input MemberRemoveInput @join__type(graph: IAM_ADMIN) { + currency: CurrencyCode! """ - Organization ID. + The price amount in minor units. """ - organizationId: ID! + amountMinor: BigInt! """ - User ID of the member to remove. + The compare-at price in minor units (optional). """ - userId: ID! + compareAtMinor: BigInt +} + +input ProductSortInput @join__type(graph: CATALOG_ADMIN) { + by: ProductSortBy! + direction: SortDirection +} + +input ProductProductsMetaInput @join__type(graph: CATALOG_ADMIN) { + categoriesScope: ProductCategoriesScopeInput +} + +input ProductCategoriesScopeInput @join__type(graph: CATALOG_ADMIN) { + referenceIds: [ID!]! + mode: CategoryHierarchyScopeMode! } """ -Filter operators for String fields +Input for rich text content (all fields required). """ -input StringFilter @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) { +input RichTextInput @join__type(graph: CATALOG_ADMIN) { """ - Equals + Plain text content. """ - _eq: String + text: String! """ - Not equals + HTML content. """ - _neq: String + html: String! """ - In array + EditorJS JSON content. """ - _in: [String!] + json: JSON! +} + +""" +Input for creating a vendor. +""" +input VendorCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Not in array + The display name of the vendor. """ - _notIn: [String!] + name: String! +} + +""" +Input for creating a feature during product creation. +""" +input ProductFeatureInput @join__type(graph: CATALOG_ADMIN) { """ - Is null + The URL-friendly slug for the feature. """ - _is: Boolean + slug: String! """ - Is not null + Display name. """ - _isNot: Boolean + name: String! """ - Contains substring (case-sensitive) + The values for this feature. """ - _contains: String + values: [ProductFeatureValueCreateInput!]! +} + +""" +Input for creating an option value during product creation. +""" +input ProductCreateOptionValueInput @join__type(graph: CATALOG_ADMIN) { """ - Contains substring (case-insensitive) + Display name for the value. """ - _containsi: String + name: String! """ - Starts with (case-sensitive) + URL-friendly slug for the value. """ - _startsWith: String + slug: String! """ - Starts with (case-insensitive) + Sort order within the option values list. """ - _startsWithi: String + sortIndex: Int } """ -Filter operators for ID fields +Input for creating an option during product creation. """ -input IDFilter @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) { +input ProductCreateOptionInput @join__type(graph: CATALOG_ADMIN) { """ - Equals + Display name for the option. """ - _eq: ID + name: String! """ - Not equals + URL-friendly slug for the option. """ - _neq: ID + slug: String! """ - In array + How to display the option (default: DROPDOWN). """ - _in: [ID!] + displayType: String """ - Not in array + Sort order within the product options list. """ - _notIn: [ID!] + sortIndex: Int """ - Is null + The values for this option. """ - _is: Boolean + values: [ProductCreateOptionValueInput!]! +} + +""" +Input for creating a variant during product creation. +""" +input ProductCreateVariantInput @join__type(graph: CATALOG_ADMIN) { """ - Is not null + Handle built from option value slugs (e.g., "red-s"). """ - _isNot: Boolean + handle: String! } """ -Filter operators for DateTime fields +Inventory tracking settings for product creation. """ -input DateTimeFilter @join__type(graph: IAM_ADMIN) @join__type(graph: INVENTORY_ADMIN) { +input InventoryItemInput @join__type(graph: CATALOG_ADMIN) { """ - Equals + Whether to track inventory for this product. """ - _eq: DateTime + tracked: Boolean! """ - Not equals + Stock Keeping Unit. """ - _neq: DateTime + sku: String """ - Greater than (after) + Allow sales when stock is zero. """ - _gt: DateTime + continueSellingWhenOutOfStock: Boolean +} + +""" +Input for creating a product with all its data in one request. +""" +input ProductCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Greater than or equal (on or after) + Product title. """ - _gte: DateTime + title: String! """ - Less than (before) + URL-friendly handle for the product. """ - _lt: DateTime + handle: String! """ - Less than or equal (on or before) + Vendor ID to associate with the product. """ - _lte: DateTime + vendorId: ID """ - Is null + Product description. """ - _is: Boolean + description: RichTextInput """ - Is not null + Short excerpt in multiple formats. """ - _isNot: Boolean -} - -""" -Input for creating a role. -""" -input RoleCreateInput @join__type(graph: IAM_ADMIN) { + excerpt: RichTextInput """ - Organization ID where the role will be created. + File IDs for product media (already uploaded via mediaMutation.fileUpload). """ - organizationId: ID! + mediaFileIds: [ID!] """ - Domain scope for role. - - "org" = organization-level role - - "store:{uuid}" = store-specific role + Product options (e.g., Color, Size). """ - domain: String! + options: [ProductCreateOptionInput!] """ - Unique role name (slug). + Variants to create (only enabled ones from UI). """ - name: String! + variants: [ProductCreateVariantInput!] """ - Display name. + Inventory tracking settings for the product. """ - displayName: String! + inventoryItem: InventoryItemInput +} + +""" +Input for deleting a product. +""" +input ProductDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Description. + The ID of the product to delete. """ - description: String + id: ID! """ - Role permissions. + Whether to permanently delete the product (hard delete). """ - permissions: [RolePermissionInput!]! + permanent: Boolean } """ -Input for role permission. +Input for product content (description, excerpt). """ -input RolePermissionInput @join__type(graph: IAM_ADMIN) { +input ProductContentInput @join__type(graph: CATALOG_ADMIN) { """ - Resource (e.g.: org.profile, store.members). + Product description in multiple formats. """ - resource: String! + description: RichTextInput """ - Action level (read, write, admin). Higher levels include lower ones. + Short excerpt. """ - action: Action! + excerpt: RichTextInput } """ -Input for updating a role. +Input for product media. """ -input RoleUpdateInput @join__type(graph: IAM_ADMIN) { +input ProductMediaInput @join__type(graph: CATALOG_ADMIN) { """ - Organization ID where the role exists. + File IDs for product media. """ - organizationId: ID! + fileIds: [ID!]! +} + +""" +Product category assignment operation for unified product updates. +""" +input ProductCategoryOperationInput @join__type(graph: CATALOG_ADMIN) { """ - Role ID to update. + The category to update for the product. """ - id: ID! + categoryId: ID! """ - New display name. + The assignment action to apply. """ - displayName: String + action: ProductCategoryOperationAction! """ - New description. + Move this product after another product in the category listing. """ - description: String + afterProductId: ID """ - New permissions (completely replaces existing). + Move this product before another product in the category listing. """ - permissions: [RolePermissionInput!] + beforeProductId: ID } """ -Input for deleting a role. +Product tag assignment operation for unified product updates. """ -input RoleDeleteInput @join__type(graph: IAM_ADMIN) { +input ProductTagOperationInput @join__type(graph: CATALOG_ADMIN) { """ - Organization ID where the role exists. + The tag to update for the product. """ - organizationId: ID! + tagId: ID! """ - Role ID to delete. + The assignment action to apply. """ - id: ID! + action: ProductTagOperationAction! } """ -Input for authorize check. +Input for product-level fields in the unified update. """ -input AuthorizeInput @join__type(graph: IAM_ADMIN) { +input ProductUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Organization ID. + The URL-friendly handle for the product. """ - organizationId: ID! + handle: String """ - Domain ("org" for organization, or "store:{uuid}"). + Product title. """ - domain: String! + title: String """ - Resource to check. + Vendor ID to associate with the product. Pass null to clear. """ - resource: String! + vendorId: ID """ - Action to check. + Product content (description, excerpt). """ - action: String! -} - -""" -Input for updating user profile. -""" -input UserUpdateProfileInput @join__type(graph: IAM_ADMIN) { + content: ProductContentInput """ - User's first name. + SEO and Open Graph metadata. """ - firstName: String + seo: ProductSeoInput """ - User's last name. + Product status: DRAFT or PUBLISHED. """ - lastName: String + status: ProductStatus """ - User's locale/language preference. + Product media. """ - locale: LocaleCode + media: ProductMediaInput + """ + Product category assignment operations. + """ + categories: [ProductCategoryOperationInput!] + """ + Product tag assignment operations. + """ + tags: [ProductTagOperationInput!] + """ + Variant create, update, and delete operations. + """ + variants: [VariantOperationInput!] } """ -Input for updating user email. +Input for variant pricing in the unified update. """ -input UserUpdateEmailInput @join__type(graph: IAM_ADMIN) { +input VariantPricingOpInput @join__type(graph: CATALOG_ADMIN) { """ - New email address. + The currency code. """ - newEmail: Email! + currency: CurrencyCode! + """ + The price amount in minor units. + """ + amountMinor: BigInt! + """ + The compare-at price in minor units (optional). + """ + compareAtMinor: BigInt } """ -Input for admin user sign up. +Input for variant inventory in the unified update. """ -input UserSignUpInput @join__type(graph: IAM_ADMIN) { +input VariantInventoryOpInput @join__type(graph: CATALOG_ADMIN) { """ - Email address. + The warehouse ID. """ - email: Email! + warehouseId: ID! """ - Password. + Quantity on hand. """ - password: String! + onHand: Int! + """ + Unavailable quantity (reserved, damaged, etc.). + """ + unavailable: Int + """ + SKU code. + """ + sku: String + """ + Unit cost in minor units (cents). + """ + unitCostMinor: BigInt + """ + Currency code for unit cost. + """ + costCurrency: CurrencyCode } """ -Input for admin user authentication. +Input for variant dimensions in the unified update. """ -input UserSignInInput @join__type(graph: IAM_ADMIN) { +input VariantDimensionsOpInput @join__type(graph: CATALOG_ADMIN) { """ - Email address. + Width in millimeters. """ - email: Email! + width: Int! """ - Password. + Height in millimeters. """ - password: String! + height: Int! + """ + Length in millimeters. + """ + length: Int! } """ -Input for admin user sign out. +Input for variant media in the unified update. """ -input UserSignOutInput @join__type(graph: IAM_ADMIN) { +input VariantMediaOpInput @join__type(graph: CATALOG_ADMIN) { """ - Sign out from all sessions. + File IDs for variant media. """ - allSessions: Boolean + fileIds: [ID!]! } """ -Input for refreshing admin user access token. +Input for variant options in the unified update. """ -input UserTokenRefreshInput @join__type(graph: IAM_ADMIN) { +input VariantOptionsOpInput @join__type(graph: CATALOG_ADMIN) { """ - Refresh token to use for obtaining new access token. + Option value links to set (replaces existing). """ - refreshToken: String! + set: [VariantOptionLinkInput!]! } """ -Input for updating user password. +Input for a single variant operation. """ -input UserUpdatePasswordInput @join__type(graph: IAM_ADMIN) { +input VariantOperationInput @join__type(graph: CATALOG_ADMIN) { """ - Current password. + The operation to apply. """ - currentPassword: String! + action: VariantOperationAction! """ - New password. + The variant ID for update/delete operations. """ - newPassword: String! -} - -""" -Filter operators for Int fields -""" -input IntFilter @join__type(graph: INVENTORY_ADMIN) { + variantId: ID """ - Equals + Per-request client correlation key for create operations. """ - _eq: Int + clientMutationId: String """ - Not equals + Variant pricing. """ - _neq: Int + pricing: VariantPricingOpInput """ - Greater than + Variant inventory item data (stock, SKU, cost). """ - _gt: Int + inventory: VariantInventoryOpInput """ - Greater than or equal + Variant dimensions. """ - _gte: Int + dimensions: VariantDimensionsOpInput """ - Less than + Variant weight in grams. """ - _lt: Int + weight: Int """ - Less than or equal + Variant media. """ - _lte: Int + media: VariantMediaOpInput """ - In array + Variant options. """ - _in: [Int!] + options: VariantOptionsOpInput +} + +""" +Input for SEO and Open Graph metadata. +""" +input SeoInput @join__type(graph: CATALOG_ADMIN) { """ - Not in array + SEO title (max 70 chars). """ - _notIn: [Int!] + seoTitle: String + """ + SEO description (max 160 chars). + """ + seoDescription: String + """ + Open Graph title (max 95 chars). + """ + ogTitle: String + """ + Open Graph description. + """ + ogDescription: String + """ + Open Graph image file ID. + """ + ogImageId: ID +} + +""" +Input for updating product SEO data. +""" +input ProductSeoInput @join__type(graph: CATALOG_ADMIN) { + seoTitle: String + seoDescription: String + ogTitle: String + ogDescription: String + ogImageId: ID +} + +""" +Input for creating a warehouse. +""" +input WarehouseCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Is null + The unique code for the warehouse. """ - _is: Boolean + code: String! """ - Is not null + The display name for the warehouse. """ - _isNot: Boolean + name: String! """ - Between range (inclusive) + Whether this should be the default warehouse. """ - _between: [Int!] + isDefault: Boolean } """ -Filter operators for Float fields +Input for updating a warehouse. """ -input FloatFilter @join__type(graph: INVENTORY_ADMIN) { - """ - Equals - """ - _eq: Float +input WarehouseUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Not equals + The ID of the warehouse to update. """ - _neq: Float + id: ID! """ - Greater than + The new code for the warehouse. """ - _gt: Float + code: String """ - Greater than or equal + The new name for the warehouse. """ - _gte: Float + name: String """ - Less than + Whether this should be the default warehouse. """ - _lt: Float + isDefault: Boolean +} + +""" +Input for deleting a warehouse. +""" +input WarehouseDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Less than or equal + The ID of the warehouse to delete. """ - _lte: Float + id: ID! +} + +""" +Item input for creating variant stock in a warehouse. +""" +input WarehouseStockCreateItemInput @join__type(graph: CATALOG_ADMIN) { """ - In array + The variant whose stock should be added. """ - _in: [Float!] + variantId: ID! """ - Not in array + The warehouse to add stock to. """ - _notIn: [Float!] + warehouseId: ID! +} + +""" +Input for creating variant stock in warehouses. +""" +input WarehouseStockCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Is null + Stock records to create. """ - _is: Boolean + items: [WarehouseStockCreateItemInput!]! +} + +""" +Item input for deleting variant stock from a warehouse. +""" +input WarehouseStockDeleteItemInput @join__type(graph: CATALOG_ADMIN) { """ - Is not null + The variant whose stock should be removed. """ - _isNot: Boolean + variantId: ID! """ - Between range (inclusive) + The warehouse to remove stock from. """ - _between: [Float!] + warehouseId: ID! } """ -Filter operators for Boolean fields +Input for deleting variant stock from warehouses. """ -input BooleanFilter @join__type(graph: INVENTORY_ADMIN) { - """ - Equals - """ - _eq: Boolean +input WarehouseStockDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Not equals + Stock records to delete. """ - _neq: Boolean + items: [WarehouseStockDeleteItemInput!]! +} + +""" +Input for creating a tag. +""" +input TagCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Is null + The URL-friendly handle for the tag. """ - _is: Boolean + handle: String! """ - Is not null + The display name of the tag (optional, defaults to handle). """ - _isNot: Boolean + name: String } """ -Filter conditions for Warehouse +Input for updating a tag. """ -input WarehouseWhereInput @join__type(graph: INVENTORY_ADMIN) { +input TagUpdateInput @join__type(graph: CATALOG_ADMIN) { """ - Logical AND of multiple conditions - """ - _and: [WarehouseWhereInput!] - """ - Logical OR of multiple conditions + The ID of the tag to update. """ - _or: [WarehouseWhereInput!] + id: ID! """ - Negate the condition + The URL-friendly handle for the tag. """ - _not: WarehouseWhereInput + handle: String """ - Filter by id + The display name of the tag. """ - id: IDFilter + name: String +} + +""" +Input for deleting a tag. +""" +input TagDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Filter by code + The ID of the tag to delete. """ - code: StringFilter + id: ID! +} + +""" +Input for creating a variant. +""" +input VariantInput @join__type(graph: CATALOG_ADMIN) { """ - Filter by name + Variant title. """ - name: StringFilter + title: String """ - Filter by isDefault + External system identifier. """ - isDefault: BooleanFilter + externalSystem: String """ - Filter by createdAt + External ID in the external system. """ - createdAt: DateTimeFilter + externalId: String """ - Filter by updatedAt + Selected option values for the variant (required). """ - updatedAt: DateTimeFilter + options: [SelectedOptionInput!]! } """ -Ordering configuration for Warehouse +Input for creating a variant with a product ID. """ -input WarehouseOrderByInput @join__type(graph: INVENTORY_ADMIN) { +input VariantCreateInput @join__type(graph: CATALOG_ADMIN) { """ - Field to order by + The ID of the product to add the variant to. """ - field: WarehouseOrderField! + productId: ID! """ - Sort direction + The variant data. """ - direction: SortDirection! + variant: VariantInput! } """ -Relay-style pagination input for Warehouse +Input for linking a variant to an option value. """ -input WarehouseConnectionInput @join__type(graph: INVENTORY_ADMIN) { +input VariantOptionLinkInput @join__type(graph: CATALOG_ADMIN) { """ - Returns the first n items + The option ID. """ - first: Int + optionId: ID! """ - Returns items after this cursor + The option value ID. """ - after: String + optionValueId: ID! +} + +""" +Input for updating variant options (option value links). +""" +input VariantUpdateOptionsInput @join__type(graph: CATALOG_ADMIN) { """ - Returns the last n items + The ID of the variant. """ - last: Int + variantId: ID! """ - Returns items before this cursor + The option value links to set (replaces existing links). """ - before: String + links: [VariantOptionLinkInput!]! +} + +""" +Input for deleting a variant. +""" +input VariantDeleteInput @join__type(graph: CATALOG_ADMIN) { """ - Filter conditions + The ID of the variant to delete. """ - where: WarehouseWhereInput + id: ID! """ - Sort order + Whether to permanently delete the variant (hard delete). """ - orderBy: [WarehouseOrderByInput!] + permanent: Boolean } """ -Filter conditions for WarehouseStock +Filter conditions for Organization """ -input WarehouseStockWhereInput @join__type(graph: INVENTORY_ADMIN) { +input OrganizationWhereInput @join__type(graph: IAM_ADMIN) { """ Logical AND of multiple conditions """ - _and: [WarehouseStockWhereInput!] + _and: [OrganizationWhereInput!] """ Logical OR of multiple conditions """ - _or: [WarehouseStockWhereInput!] + _or: [OrganizationWhereInput!] """ Negate the condition """ - _not: WarehouseStockWhereInput + _not: OrganizationWhereInput """ Filter by id """ id: IDFilter """ - Filter by warehouseId + Filter by name """ - warehouseId: IDFilter + name: StringFilter """ - Filter by variantId + Filter by displayName """ - variantId: IDFilter + displayName: StringFilter """ - Filter by quantityOnHand + Filter by createdAt """ - quantityOnHand: IntFilter + createdAt: DateTimeFilter """ Filter by updatedAt """ updatedAt: DateTimeFilter - """ - Filter by createdAt - """ - createdAt: DateTimeFilter } """ -Ordering configuration for WarehouseStock +Ordering configuration for Organization """ -input WarehouseStockOrderByInput @join__type(graph: INVENTORY_ADMIN) { +input OrganizationOrderByInput @join__type(graph: IAM_ADMIN) { """ Field to order by """ - field: WarehouseStockOrderField! + field: OrganizationOrderField! """ Sort direction """ @@ -5792,655 +11442,673 @@ input WarehouseStockOrderByInput @join__type(graph: INVENTORY_ADMIN) { } """ -Relay-style pagination input for WarehouseStock +Input for creating an organization. """ -input WarehouseStockConnectionInput @join__type(graph: INVENTORY_ADMIN) { - """ - Returns the first n items - """ - first: Int - """ - Returns items after this cursor - """ - after: String - """ - Returns the last n items - """ - last: Int - """ - Returns items before this cursor - """ - before: String +input OrganizationCreateInput @join__type(graph: IAM_ADMIN) { """ - Filter conditions + URL-friendly unique identifier. """ - where: WarehouseStockWhereInput + name: String! """ - Sort order + Display name. """ - orderBy: [WarehouseStockOrderByInput!] + displayName: String! } """ -Input for creating a feature value. +Role assignment - assigns role to user in specific domain. """ -input ProductFeatureValueCreateInput @join__type(graph: INVENTORY_ADMIN) { +input RoleAssignment @join__type(graph: IAM_ADMIN) { """ - The URL-friendly slug for the value. + Domain ID ("org" for organization, or "store:{uuid}"). """ - slug: String! + domain: String! """ - Display name. + Role name. """ - name: String! + role: String! } """ -Input for creating a feature on a product. +Input for inviting a member to organization. """ -input ProductFeatureCreateInput @join__type(graph: INVENTORY_ADMIN) { - """ - The ID of the product. - """ - productId: ID! +input MemberInviteInput @join__type(graph: IAM_ADMIN) { """ - The URL-friendly slug for the feature. + Organization ID to invite the member to. """ - slug: String! + organizationId: ID! """ - Display name. + Email address of the user to invite. """ - name: String! + email: Email! """ - The values for this feature. + Role assignments (at least one required). """ - values: [ProductFeatureValueCreateInput!]! + roles: [RoleAssignment!]! } """ -Input for updating an existing feature value. +Input for changing member's role. """ -input ProductFeatureValueUpdateInput @join__type(graph: INVENTORY_ADMIN) { +input MemberRoleChangeInput @join__type(graph: IAM_ADMIN) { """ - The ID of the value to update. + Organization ID where the member belongs. """ - id: ID! + organizationId: ID! """ - The new slug for the value. + User ID. """ - slug: String + userId: ID! """ - Display name. + Domain ("org" for organization, or "store:{uuid}"). """ - name: String + domain: String! + """ + New role name. + """ + role: String! } """ -Input for nested value operations in feature update. +Input for removing member's access. """ -input ProductFeatureValuesInput @join__type(graph: INVENTORY_ADMIN) { +input MemberAccessRemoveInput @join__type(graph: IAM_ADMIN) { """ - Values to create. + Organization ID where the member belongs. """ - create: [ProductFeatureValueCreateInput!] + organizationId: ID! """ - Values to update. + User ID. """ - update: [ProductFeatureValueUpdateInput!] + userId: ID! """ - IDs of values to delete. + Domain to remove access from. """ - delete: [ID!] + domain: String! } """ -Input for updating a feature. +Input for updating organization. """ -input ProductFeatureUpdateInput @join__type(graph: INVENTORY_ADMIN) { +input OrganizationUpdateInput @join__type(graph: IAM_ADMIN) { """ - The ID of the feature to update. + Organization ID. """ id: ID! """ - The new slug for the feature. - """ - slug: String - """ - Display name. + New name (URL-friendly identifier). """ name: String """ - Nested value operations. + New display name. """ - values: ProductFeatureValuesInput -} - -""" -Input for deleting a feature from a product. -""" -input ProductFeatureDeleteInput @join__type(graph: INVENTORY_ADMIN) { + displayName: String """ - The ID of the feature to delete. + Media file ID for the logo. Pass null to remove logo. """ - id: ID! + logoId: ID } """ -Input for setting variant media (replaces all existing media). +Input for transferring organization ownership. """ -input VariantSetMediaInput @join__type(graph: INVENTORY_ADMIN) { +input OwnershipTransferInput @join__type(graph: IAM_ADMIN) { """ - The variant ID. + Organization ID. """ - variantId: ID! + organizationId: ID! """ - File IDs in desired order (first = primary). Empty array clears all media. + User ID of the new owner. Must be an admin of the organization. """ - fileIds: [ID!]! + newOwnerId: ID! } """ -Input for selecting an option value for a variant. +Input for removing a member from organization. """ -input SelectedOptionInput @join__type(graph: INVENTORY_ADMIN) { +input MemberRemoveInput @join__type(graph: IAM_ADMIN) { """ - The ID of the option. + Organization ID. """ - optionId: ID! + organizationId: ID! """ - The ID of the option value. + User ID of the member to remove. """ - optionValueId: ID! + userId: ID! } """ -Input for creating/updating a swatch. +Input for creating a role. """ -input ProductOptionSwatchInput @join__type(graph: INVENTORY_ADMIN) { +input RoleCreateInput @join__type(graph: IAM_ADMIN) { """ - The type of swatch. + Organization ID where the role will be created. """ - swatchType: SwatchType! + organizationId: ID! """ - The primary color (hex code or color name). + Domain scope for role. + - "org" = organization-level role + - "store:{uuid}" = store-specific role """ - colorOne: String + domain: String! """ - The secondary color for gradients. + Unique role name (slug). """ - colorTwo: String + name: String! """ - The file ID for image-based swatches. + Display name. """ - fileId: ID + displayName: String! """ - Additional metadata. + Description. """ - metadata: JSON + description: String + """ + Role permissions. + """ + permissions: [RolePermissionInput!]! } """ -Input for creating an option value. +Input for role permission. """ -input ProductOptionValueCreateInput @join__type(graph: INVENTORY_ADMIN) { - """ - The URL-friendly slug for the value. - """ - slug: String! +input RolePermissionInput @join__type(graph: IAM_ADMIN) { """ - Display name. + Resource (e.g.: org.profile, store.members). """ - name: String! + resource: String! """ - The swatch for this value. + Action level (read, write, admin). Higher levels include lower ones. """ - swatch: ProductOptionSwatchInput + action: Action! } """ -Input for creating an option on a product. +Input for updating a role. """ -input ProductOptionCreateInput @join__type(graph: INVENTORY_ADMIN) { +input RoleUpdateInput @join__type(graph: IAM_ADMIN) { """ - The ID of the product (optional when creating with product). + Organization ID where the role exists. """ - productId: ID + organizationId: ID! """ - The URL-friendly slug for the option. + Role ID to update. """ - slug: String! + id: ID! """ - Display name. + New display name. """ - name: String! + displayName: String """ - The display type for UI rendering. + New description. """ - displayType: OptionDisplayType! + description: String """ - The values for this option. + New permissions (completely replaces existing). """ - values: [ProductOptionValueCreateInput!]! + permissions: [RolePermissionInput!] } """ -Input for updating an existing option value. +Input for deleting a role. """ -input ProductOptionValueUpdateInput @join__type(graph: INVENTORY_ADMIN) { - """ - The ID of the value to update. - """ - id: ID! - """ - The new slug for the value. - """ - slug: String +input RoleDeleteInput @join__type(graph: IAM_ADMIN) { """ - Display name. + Organization ID where the role exists. """ - name: String + organizationId: ID! """ - The swatch for this value. + Role ID to delete. """ - swatch: ProductOptionSwatchInput + id: ID! } """ -Input for nested value operations in option update. +Input for authorize check. """ -input ProductOptionValuesInput @join__type(graph: INVENTORY_ADMIN) { +input AuthorizeInput @join__type(graph: IAM_ADMIN) { """ - Values to create. + Organization ID. """ - create: [ProductOptionValueCreateInput!] + organizationId: ID! """ - Values to update. + Domain ("org" for organization, or "store:{uuid}"). """ - update: [ProductOptionValueUpdateInput!] + domain: String! """ - IDs of values to delete. + Resource to check. """ - delete: [ID!] + resource: String! + """ + Action to check. + """ + action: String! } """ -Input for updating an option. +Input for revoking a specific session. """ -input ProductOptionUpdateInput @join__type(graph: INVENTORY_ADMIN) { +input SessionRevokeInput @join__type(graph: IAM_ADMIN) { """ - The ID of the option to update. + The ID of the session to revoke. """ - id: ID! + sessionId: ID! +} + +""" +Input for updating user profile. +""" +input UserUpdateProfileInput @join__type(graph: IAM_ADMIN) { """ - The new slug for the option. + User's first name. """ - slug: String + firstName: String """ - Display name. + User's last name. """ - name: String + lastName: String """ - The new display type. + User's locale/language preference. """ - displayType: OptionDisplayType + locale: LocaleCode """ - Nested value operations. + Media file ID for the avatar. Pass null to remove avatar. """ - values: ProductOptionValuesInput + avatarId: ID } """ -Input for deleting an option from a product. +Input for updating user email. """ -input ProductOptionDeleteInput @join__type(graph: INVENTORY_ADMIN) { +input UserUpdateEmailInput @join__type(graph: IAM_ADMIN) { """ - The ID of the option to delete. + New email address. """ - id: ID! + newEmail: Email! } """ -Input for setting dimensions (in millimeters). +Input for admin user sign up. """ -input DimensionsInput @join__type(graph: INVENTORY_ADMIN) { - """ - Width in millimeters. - """ - width: Int! +input UserSignUpInput @join__type(graph: IAM_ADMIN) { """ - Length in millimeters. + Email address. """ - length: Int! + email: Email! """ - Height in millimeters. + Password. """ - height: Int! + password: String! } """ -Input for setting weight (in grams). +Input for admin user authentication. """ -input WeightInput @join__type(graph: INVENTORY_ADMIN) { +input UserSignInInput @join__type(graph: IAM_ADMIN) { """ - Weight in grams. + Email address. """ - value: Int! + email: Email! + """ + Password. + """ + password: String! } """ -Input for setting a price on a variant. +Input for admin user sign out. """ -input VariantSetPricingInput @join__type(graph: INVENTORY_ADMIN) { - """ - The ID of the variant. - """ - variantId: ID! - """ - The currency code. - """ - currency: CurrencyCode! - """ - The price amount in minor units. - """ - amountMinor: BigInt! +input UserSignOutInput @join__type(graph: IAM_ADMIN) { """ - The compare-at price in minor units (optional). + Sign out from all sessions. """ - compareAtMinor: BigInt + allSessions: Boolean } """ -Input for setting a cost on a variant. +Input for refreshing admin user access token. """ -input VariantSetCostInput @join__type(graph: INVENTORY_ADMIN) { - """ - The ID of the variant. - """ - variantId: ID! - """ - The currency code. - """ - currency: CurrencyCode! +input UserTokenRefreshInput @join__type(graph: IAM_ADMIN) { """ - The unit cost in minor units. + Refresh token to use for obtaining new access token. """ - unitCostMinor: BigInt! + refreshToken: String! } """ -Input for product description (all fields required). +Input for updating user password. """ -input DescriptionInput @join__type(graph: INVENTORY_ADMIN) { - """ - Plain text description. - """ - text: String! +input UserUpdatePasswordInput @join__type(graph: IAM_ADMIN) { """ - HTML description. + Current password. """ - html: String! + currentPassword: String! """ - EditorJS JSON description. + New password. """ - json: JSON! + newPassword: String! } -""" -Input for creating a feature during product creation. -""" -input ProductFeatureInput @join__type(graph: INVENTORY_ADMIN) { - """ - The URL-friendly slug for the feature. - """ +input FacetCreateSourceInput @join__type(graph: LISTING_ADMIN) { + handle: String! + name: String! +} + +input FacetCreateValueCandidateInput @join__type(graph: LISTING_ADMIN) { + handle: String! + label: String! + sourceHandle: String! +} + +input FacetValueCandidatesMetaInput @join__type(graph: LISTING_ADMIN) { + candidateType: FacetValueCandidateType! + sourceHandles: [String!] + facetId: ID +} + +input FacetCreateInput @join__type(graph: LISTING_ADMIN) { + facetType: FacetType! slug: String! + uiType: FacetUIType + selectionMode: FacetSelectionMode + label: String! + sources: [FacetCreateSourceInput!] + valueCandidates: [FacetCreateValueCandidateInput!] +} + +input FacetUpdateInput @join__type(graph: LISTING_ADMIN) { + id: ID! + slug: String + uiType: FacetUIType + selectionMode: FacetSelectionMode + label: String +} + +input FacetDeleteInput @join__type(graph: LISTING_ADMIN) { + id: ID! +} + +input FacetMoveInput @join__type(graph: LISTING_ADMIN) { + id: ID! + afterFacetId: ID + beforeFacetId: ID +} + +input FacetRebalanceInput @join__type(graph: LISTING_ADMIN) { + confirm: Boolean = true +} + +input FacetValueCreateInput @join__type(graph: LISTING_ADMIN) { + facetId: ID! + kind: FacetValueKind + handle: String! + label: String! + sourceValueIds: [ID!] + swatchId: ID + sortIndex: Int + enabled: Boolean +} + +input FacetValueUpdateInput @join__type(graph: LISTING_ADMIN) { + id: ID! + handle: String + label: String + swatchId: ID + sortIndex: Int + enabled: Boolean +} + +input FacetValueMergeInput @join__type(graph: LISTING_ADMIN) { + facetId: ID! + targetGroupValueId: ID + targetHandle: String + targetLabel: String + sourceValueIds: [ID!]! +} + +input FacetValueUnmergeInput @join__type(graph: LISTING_ADMIN) { + sourceValueIds: [ID!]! +} + +input FacetValueDeleteInput @join__type(graph: LISTING_ADMIN) { + id: ID! +} + +input FacetSwatchCreateInput @join__type(graph: LISTING_ADMIN) { + swatchType: SwatchType! + colorOne: String + colorTwo: String + fileId: ID + metadata: JSON +} + +input FacetSwatchUpdateInput @join__type(graph: LISTING_ADMIN) { + id: ID! + swatchType: SwatchType + colorOne: String + colorTwo: String + fileId: ID + metadata: JSON +} + +input FacetSwatchDeleteInput @join__type(graph: LISTING_ADMIN) { + id: ID! +} + +input ListingScopeInput @join__type(graph: LISTING_ADMIN) { """ - Display name. + Scope kind for the listing request. """ - name: String! + kind: ListingScopeKind! """ - The values for this feature. + Category global ID. Required when kind is CATEGORY. """ - values: [ProductFeatureValueCreateInput!]! + categoryId: ID } -""" -Input for updating a product. -""" -input ProductUpdateInput @join__type(graph: INVENTORY_ADMIN) { +input ListingProductFilter @join__type(graph: LISTING_ADMIN) { """ - The product ID. + Filter on if the listing item is available. """ - id: ID! + available: Boolean """ - The URL-friendly handle for the product. + Filter by product price range. """ - handle: String + price: ListingPriceRangeFilter """ - Product title. + Filter by product vendor. """ - title: String + productVendor: String """ - Product description. + Filter by product tag. """ - description: DescriptionInput + tag: String """ - Short excerpt. + Filter by variant option. """ - excerpt: String + variantOption: ListingVariantOptionFilter """ - SEO title. + Filter by product-level listing facet value. """ - seoTitle: String + productFacet: ListingFacetValueFilter """ - SEO description. + Filter by variant-level listing facet value. """ - seoDescription: String + variantFacet: ListingFacetValueFilter } -""" -Input for deleting a product. -""" -input ProductDeleteInput @join__type(graph: INVENTORY_ADMIN) { +input ListingPriceRangeFilter @join__type(graph: LISTING_ADMIN) { """ - The ID of the product to delete. + Minimum price amount in minor units. """ - id: ID! + min: BigInt """ - Whether to permanently delete the product (hard delete). + Maximum price amount in minor units. """ - permanent: Boolean + max: BigInt } -""" -Input for publishing a product. -""" -input ProductPublishInput @join__type(graph: INVENTORY_ADMIN) { +input ListingVariantOptionFilter @join__type(graph: LISTING_ADMIN) { """ - The ID of the product to publish. + Variant option name. """ - id: ID! -} - -""" -Input for unpublishing a product. -""" -input ProductUnpublishInput @join__type(graph: INVENTORY_ADMIN) { + name: String! """ - The ID of the product to unpublish. + Variant option value. """ - id: ID! + value: String! } -""" -Input for creating a warehouse. -""" -input WarehouseCreateInput @join__type(graph: INVENTORY_ADMIN) { +input ListingFacetValueFilter @join__type(graph: LISTING_ADMIN) { """ - The unique code for the warehouse. + Facet stable identifier. """ - code: String! + facet: String! """ - The display name for the warehouse. + Facet value stable identifier. """ - name: String! + value: String! +} + +input ListingOrderByInput @join__type(graph: LISTING_ADMIN) { """ - Whether this should be the default warehouse. + Sort key for the listing request. """ - isDefault: Boolean + by: ListingSortBy! + """ + Sort direction. Ignored for MANUAL and RELEVANCE. + """ + direction: ListingSortDirection } """ -Input for updating a warehouse. +Filter conditions for File """ -input WarehouseUpdateInput @join__type(graph: INVENTORY_ADMIN) { +input FileWhereInput @join__type(graph: MEDIA_ADMIN) { """ - The ID of the warehouse to update. + Logical AND of multiple conditions """ - id: ID! + _and: [FileWhereInput!] """ - The new code for the warehouse. + Logical OR of multiple conditions """ - code: String + _or: [FileWhereInput!] """ - The new name for the warehouse. + Negate the condition """ - name: String + _not: FileWhereInput """ - Whether this should be the default warehouse. + Filter by id """ - isDefault: Boolean -} - -""" -Input for deleting a warehouse. -""" -input WarehouseDeleteInput @join__type(graph: INVENTORY_ADMIN) { + id: IDFilter """ - The ID of the warehouse to delete. + Filter by provider """ - id: ID! -} - -""" -Input for setting stock on a variant. -""" -input VariantSetStockInput @join__type(graph: INVENTORY_ADMIN) { + provider: StringFilter """ - The ID of the variant. + Filter by url """ - variantId: ID! + url: StringFilter """ - The warehouse ID. + Filter by mimeType """ - warehouseId: ID! + mimeType: StringFilter """ - The quantity to set. + Filter by ext """ - quantity: Int! -} - -""" -Input for creating a variant. -""" -input VariantInput @join__type(graph: INVENTORY_ADMIN) { + ext: StringFilter """ - Variant title. + Filter by sizeBytes """ - title: String + sizeBytes: IntFilter """ - The SKU for the variant. + Filter by originalName """ - sku: String + originalName: StringFilter """ - External system identifier. + Filter by width """ - externalSystem: String + width: IntFilter """ - External ID in the external system. + Filter by height """ - externalId: String + height: IntFilter """ - Selected option values for the variant (required). + Filter by durationMs """ - options: [SelectedOptionInput!]! + durationMs: IntFilter """ - Physical dimensions. + Filter by altText """ - dimensions: DimensionsInput + altText: StringFilter """ - Physical weight. + Filter by sourceUrl """ - weight: WeightInput -} - -""" -Input for creating a variant with a product ID. -""" -input VariantCreateInput @join__type(graph: INVENTORY_ADMIN) { + sourceUrl: StringFilter """ - The ID of the product to add the variant to. + Filter by idempotencyKey """ - productId: ID! + idempotencyKey: StringFilter """ - The variant data. + Filter by isProcessed """ - variant: VariantInput! -} - -""" -Input for setting variant SKU. -""" -input VariantSetSkuInput @join__type(graph: INVENTORY_ADMIN) { + isProcessed: BooleanFilter """ - The ID of the variant. + Filter by meta """ - variantId: ID! + meta: StringFilter + """ + Filter by createdAt + """ + createdAt: DateTimeFilter """ - The new SKU value. + Filter by updatedAt """ - sku: String! + updatedAt: DateTimeFilter } """ -Input for setting variant dimensions. +Ordering configuration for File """ -input VariantSetDimensionsInput @join__type(graph: INVENTORY_ADMIN) { +input FileOrderByInput @join__type(graph: MEDIA_ADMIN) { """ - The ID of the variant. + Field to order by """ - variantId: ID! + field: FileOrderField! """ - The dimensions to set. + Sort direction """ - dimensions: DimensionsInput! + direction: SortDirection! } """ -Input for setting variant weight. +Relay-style pagination input for File """ -input VariantSetWeightInput @join__type(graph: INVENTORY_ADMIN) { +input FileConnectionInput @join__type(graph: MEDIA_ADMIN) { """ - The ID of the variant. + Returns the first n items """ - variantId: ID! + first: Int """ - The weight to set. + Returns items after this cursor """ - weight: WeightInput! -} - -""" -Input for deleting a variant. -""" -input VariantDeleteInput @join__type(graph: INVENTORY_ADMIN) { + after: String """ - The ID of the variant to delete. + Returns the last n items """ - id: ID! + last: Int """ - Whether to permanently delete the variant (hard delete). + Returns items before this cursor """ - permanent: Boolean + before: String + """ + Filter conditions + """ + where: FileWhereInput + """ + Sort order + """ + orderBy: [FileOrderByInput!] } """ @@ -6471,6 +12139,7 @@ input BucketCreateInput @join__type(graph: MEDIA_ADMIN) { """ Input for uploading a file via multipart form data. +Store context is determined from x-store-name header. """ input FileUploadMultipartInput @join__type(graph: MEDIA_ADMIN) { """ @@ -6489,6 +12158,7 @@ input FileUploadMultipartInput @join__type(graph: MEDIA_ADMIN) { """ Input for uploading a file from URL. +Store context is determined from x-store-name header. """ input FileUploadFromUrlInput @join__type(graph: MEDIA_ADMIN) { """ @@ -6507,6 +12177,7 @@ input FileUploadFromUrlInput @join__type(graph: MEDIA_ADMIN) { """ Input for creating an external media file (YouTube, Vimeo, etc). +Store context is determined from x-store-name header. """ input FileCreateExternalInput @join__type(graph: MEDIA_ADMIN) { """ @@ -6591,6 +12262,53 @@ input FileDeleteInput @join__type(graph: MEDIA_ADMIN) { permanent: Boolean } +input FileDeleteManyInput @join__type(graph: MEDIA_ADMIN) { + """ + The IDs of files to delete. + """ + ids: [ID!]! + """ + Whether to permanently delete the files (hard delete). + """ + permanent: Boolean +} + +input FileRestoreInput @join__type(graph: MEDIA_ADMIN) { + """ + The ID of the file to restore. + """ + id: ID! +} + +input FileRestoreManyInput @join__type(graph: MEDIA_ADMIN) { + """ + The IDs of files to restore. + """ + ids: [ID!]! +} + +input FileClearErrorInput @join__type(graph: MEDIA_ADMIN) { + """ + The ID of the file to clear deletion error for. + """ + id: ID! +} + +""" +Input for uploading avatar or logo. +""" +input AvatarUploadInput @join__type(graph: MEDIA_ADMIN) { + """ + Owner ID (User or Organization global ID). + The asset group will be resolved by this ID. + """ + ownerId: ID! + """ + The file to upload. + """ + file: Upload! +} + input OrderAdminNoteUpdateInput @join__type(graph: ORDERS_ADMIN) { orderId: ID! note: String! @@ -6821,4 +12539,4 @@ input StoreDeleteInput @join__type(graph: PROJECT_ADMIN) { Organization name for authorization context """ organizationId: ID! -} \ No newline at end of file +} diff --git a/admin/src/app/layout.tsx b/admin/src/app/layout.tsx index e3213f5c7..795f5196e 100644 --- a/admin/src/app/layout.tsx +++ b/admin/src/app/layout.tsx @@ -1,7 +1,6 @@ import { Theme } from "@/ui-kit/theme"; import { ApolloProvider } from "@/graphql/apollo-provider"; import { safiro } from "@/fonts/safiro"; -import { inter } from "@/fonts/inter"; import { createLayout, ClientLayoutResolver } from "@/registry"; import { modulesContext } from "@/registry/modules-context"; @@ -18,11 +17,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/admin/src/domains/apps/index.ts b/admin/src/domains/apps/index.ts new file mode 100644 index 000000000..3e5c458dc --- /dev/null +++ b/admin/src/domains/apps/index.ts @@ -0,0 +1,7 @@ +export { AdminAppsHostProvider } from "./runtime/admin-apps-host-provider"; +export { AdminAppExtensionPoint } from "./runtime/admin-app-extension-point"; +export type { + AdminAppExtensionComponent, + AdminExtensionPointMap, + AdminExtensionProps, +} from "./sdk"; diff --git a/admin/src/domains/apps/management/components/app-row.tsx b/admin/src/domains/apps/management/components/app-row.tsx new file mode 100644 index 000000000..30cea38da --- /dev/null +++ b/admin/src/domains/apps/management/components/app-row.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { Avatar, Skeleton, Tag } from "antd"; +import { createStyles } from "antd-style"; +import { LuChevronRight as RightOutlined } from "react-icons/lu"; +import { + AppInstallationStatus, + AppRuntimeStatus, +} from "@/graphql/types"; +import type { ManagementAppListItem } from "../graphql/operation-types"; + +const useStyles = createStyles(({ token }) => ({ + item: { + boxSizing: "border-box", + height: 64, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { + borderBottom: 0, + }, + }, + row: { + width: "100%", + boxSizing: "border-box", + display: "flex", + alignItems: "center", + gap: 16, + height: 64, + padding: "10px 16px", + color: "inherit", + background: token.colorBgContainer, + border: 0, + cursor: "pointer", + font: "inherit", + textDecoration: "none", + textAlign: "left", + transition: `background-color ${token.motionDurationMid}`, + "&:hover": { + background: token.colorFillQuaternary, + }, + "&:focus-visible": { + outline: `2px solid ${token.colorPrimaryBorder}`, + outlineOffset: -2, + }, + }, + avatar: { + flex: "0 0 auto", + width: 40, + height: 40, + borderRadius: token.borderRadiusLG, + fontSize: 14, + lineHeight: "40px", + }, + copy: { + display: "flex", + flex: 1, + flexDirection: "column", + minWidth: 0, + overflow: "hidden", + }, + name: { + overflow: "hidden", + color: token.colorText, + fontSize: 13, + fontWeight: token.fontWeightStrong, + lineHeight: "20px", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + description: { + overflow: "hidden", + color: token.colorTextSecondary, + fontSize: 11, + lineHeight: "18px", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + status: { + flex: "0 0 auto", + marginInlineEnd: 0, + textTransform: "capitalize", + }, + chevron: { + flex: "0 0 auto", + width: 14, + height: 14, + color: token.colorText, + }, + skeletonName: { + width: 132, + minWidth: 0, + height: 12, + marginBlock: 4, + }, + skeletonDescription: { + width: 220, + maxWidth: "100%", + minWidth: 0, + height: 10, + marginBlock: 4, + }, + skeletonStatus: { + width: 64, + minWidth: 64, + height: 22, + }, + skeletonChevron: { + flex: "0 0 auto", + width: 14, + minWidth: 14, + height: 14, + }, + skeletonRow: { + cursor: "default", + "&:hover": { + background: token.colorBgContainer, + }, + }, +})); + +const formatStatus = (status: AppInstallationStatus) => + status.toLowerCase().replaceAll("_", " "); + +const getStatusColor = (status: AppInstallationStatus) => { + if (status === AppInstallationStatus.Active) return "success"; + if ( + status === AppInstallationStatus.InstallFailed || + status === AppInstallationStatus.UninstallFailed || + status === AppInstallationStatus.UpdateFailed + ) { + return "error"; + } + if ( + status === AppInstallationStatus.Installing || + status === AppInstallationStatus.Resuming || + status === AppInstallationStatus.Suspending || + status === AppInstallationStatus.Uninstalling || + status === AppInstallationStatus.Updating + ) { + return "processing"; + } + if (status === AppInstallationStatus.PendingConsent) return "warning"; + return "default"; +}; + +export interface AppRowProps { + app: ManagementAppListItem; + onOpen: (app: ManagementAppListItem) => void; +} + +export function AppRow({ app, onOpen }: AppRowProps) { + const { styles } = useStyles(); + const installationStatus = app.installation?.status; + const isAvailable = + !app.installed && app.runtimeStatus === AppRuntimeStatus.Ready; + const status = installationStatus + ? { + color: getStatusColor(installationStatus), + label: formatStatus(installationStatus), + } + : app.installed + ? { color: "blue", label: "installed" } + : isAvailable + ? { color: "blue", label: "available" } + : { color: "default", label: "unavailable" }; + + return ( +
  • + +
  • + ); +} + +export function AppRowSkeleton() { + const { styles } = useStyles(); + + return ( +
  • +
    + + + + + + + +
    +
  • + ); +} diff --git a/admin/src/domains/apps/management/components/index.ts b/admin/src/domains/apps/management/components/index.ts new file mode 100644 index 000000000..320d9413c --- /dev/null +++ b/admin/src/domains/apps/management/components/index.ts @@ -0,0 +1,5 @@ +export { + AppRow, + AppRowSkeleton, + type AppRowProps, +} from "./app-row"; diff --git a/admin/src/domains/apps/management/graphql/index.ts b/admin/src/domains/apps/management/graphql/index.ts new file mode 100644 index 000000000..6e9fa496d --- /dev/null +++ b/admin/src/domains/apps/management/graphql/index.ts @@ -0,0 +1,7 @@ +export { + APP_INSTALL_MUTATION, + APP_RESUME_MUTATION, + APP_SUSPEND_MUTATION, + APP_UNINSTALL_MUTATION, +} from "./mutations"; +export { ADMIN_UI_APPS_QUERY, APPS_MANAGEMENT_QUERY } from "./queries"; diff --git a/admin/src/domains/apps/management/graphql/mutations.ts b/admin/src/domains/apps/management/graphql/mutations.ts new file mode 100644 index 000000000..97d27a5eb --- /dev/null +++ b/admin/src/domains/apps/management/graphql/mutations.ts @@ -0,0 +1,106 @@ +import { gql } from "@apollo/client"; + +export const APP_INSTALL_MUTATION = gql` + mutation AppInstall($input: AppInstallInput!) { + appsMutation { + appInstall(input: $input) { + installation { + id + appCode + status + installedVersion + healthStatus + } + operation { + id + type + status + } + duplicate + userErrors { + code + field + message + } + } + } + } +`; + +export const APP_SUSPEND_MUTATION = gql` + mutation AppSuspend($input: AppInstallationActionInput!) { + appsMutation { + appSuspend(input: $input) { + installation { + id + status + installedVersion + healthStatus + } + operation { + id + type + status + } + duplicate + userErrors { + code + field + message + } + } + } + } +`; + +export const APP_RESUME_MUTATION = gql` + mutation AppResume($input: AppInstallationActionInput!) { + appsMutation { + appResume(input: $input) { + installation { + id + status + installedVersion + healthStatus + } + operation { + id + type + status + } + duplicate + userErrors { + code + field + message + } + } + } + } +`; + +export const APP_UNINSTALL_MUTATION = gql` + mutation AppUninstall($input: AppInstallationActionInput!) { + appsMutation { + appUninstall(input: $input) { + installation { + id + status + installedVersion + healthStatus + } + operation { + id + type + status + } + duplicate + userErrors { + code + field + message + } + } + } + } +`; diff --git a/admin/src/domains/apps/management/graphql/operation-types.ts b/admin/src/domains/apps/management/graphql/operation-types.ts new file mode 100644 index 000000000..4556a9f9d --- /dev/null +++ b/admin/src/domains/apps/management/graphql/operation-types.ts @@ -0,0 +1,116 @@ +import type { + ApiAppDefinition, + ApiAppInstallInput, + ApiAppInstallationActionInput, + ApiAppInstallation, + ApiAppLifecycleOperation, + ApiAppWhereInput, + ApiGenericUserError, +} from "@/graphql/types"; +import type { AdminAppUiDescriptor } from "../../runtime/descriptor-schema"; + +export interface AdminUiAppsQueryData { + appsQuery: { + adminUiApps: AdminAppUiDescriptor[]; + }; +} + +export type ManagementAppListItem = Pick< + ApiAppDefinition, + | "code" + | "displayName" + | "description" + | "icon" + | "version" + | "runtimeStatus" + | "runtimeHealth" + | "installed" + | "permissions" + | "capabilities" + | "graphql" +> & { + installation: Pick< + ApiAppInstallation, + | "id" + | "status" + | "installedVersion" + | "targetVersion" + | "healthStatus" + | "installedAt" + | "suspendedAt" + | "updatedAt" + | "lastError" + > & { + lifecycleOperations: { + totalCount: number; + edges: Array<{ + node: Pick< + ApiAppLifecycleOperation, + | "id" + | "type" + | "status" + | "targetVersion" + | "actorType" + | "startedAt" + | "completedAt" + | "createdAt" + | "error" + >; + }>; + }; + } | null; +}; + +export interface AppsManagementQueryData { + appsQuery: { + apps: { + edges: Array<{ + node: ManagementAppListItem; + }>; + }; + }; +} + +export interface AppsManagementQueryVariables { + where?: ApiAppWhereInput; +} + +export interface AppInstallMutationData { + appsMutation: { + appInstall: { + installation: Pick< + ApiAppInstallation, + "id" | "appCode" | "status" | "installedVersion" | "healthStatus" + > | null; + operation: Pick | null; + duplicate: boolean; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface AppInstallMutationVariables { + input: ApiAppInstallInput; +} + +export interface AppLifecycleActionMutationData { + appsMutation: { + appSuspend?: AppLifecycleMutationPayload; + appResume?: AppLifecycleMutationPayload; + appUninstall?: AppLifecycleMutationPayload; + }; +} + +export interface AppLifecycleActionMutationVariables { + input: ApiAppInstallationActionInput; +} + +export interface AppLifecycleMutationPayload { + installation: Pick< + ApiAppInstallation, + "id" | "status" | "installedVersion" | "healthStatus" + > | null; + operation: Pick | null; + duplicate: boolean; + userErrors: ApiGenericUserError[]; +} diff --git a/admin/src/domains/apps/management/graphql/queries.ts b/admin/src/domains/apps/management/graphql/queries.ts new file mode 100644 index 000000000..06cf71d03 --- /dev/null +++ b/admin/src/domains/apps/management/graphql/queries.ts @@ -0,0 +1,127 @@ +import { gql } from "@apollo/client"; + +export const APPS_MANAGEMENT_QUERY = gql` + query AppsManagement($where: AppWhereInput) { + appsQuery { + apps(first: 100, where: $where) { + edges { + node { + code + displayName + description + icon { + url + alt + } + version + runtimeStatus + runtimeHealth { + status + message + } + installed + permissions { + scope + granted + } + capabilities { + key + assignmentMode + operations { + name + action + } + } + graphql { + admin + storefront + } + installation { + id + status + installedVersion + targetVersion + healthStatus + installedAt + suspendedAt + updatedAt + lastError { + code + message + } + lifecycleOperations(first: 10) { + totalCount + edges { + node { + id + type + status + targetVersion + actorType + startedAt + completedAt + createdAt + error { + code + message + } + } + } + } + } + } + } + } + } + } +`; + +export const ADMIN_UI_APPS_QUERY = gql` + query AdminUiApps { + appsQuery { + adminUiApps { + installationId + appCode + displayName + description + icon { + url + alt + } + version + sdkVersionRange + grantedScopes + remote { + name + manifestUrl + contentHash + } + page { + module + defaultPath + } + navigation { + id + label + path + order + } + modals { + id + module + confirmOnDirtyClose + closeConfirmMessage + requiredScopes + } + extensions { + id + point + module + priority + requiredScopes + conditions + } + } + } + } +`; diff --git a/admin/src/domains/apps/management/hooks/index.ts b/admin/src/domains/apps/management/hooks/index.ts new file mode 100644 index 000000000..7ff613d66 --- /dev/null +++ b/admin/src/domains/apps/management/hooks/index.ts @@ -0,0 +1,3 @@ +export { useAppsManagement } from "./use-apps-management"; +export { useAppLifecycleActions } from "./use-app-lifecycle-actions"; +export { useInstallApp } from "./use-install-app"; diff --git a/admin/src/domains/apps/management/hooks/use-app-lifecycle-actions.ts b/admin/src/domains/apps/management/hooks/use-app-lifecycle-actions.ts new file mode 100644 index 000000000..32e3c611d --- /dev/null +++ b/admin/src/domains/apps/management/hooks/use-app-lifecycle-actions.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiGenericUserError } from "@/graphql/types"; +import { + APP_RESUME_MUTATION, + APP_SUSPEND_MUTATION, + APP_UNINSTALL_MUTATION, + APPS_MANAGEMENT_QUERY, +} from "../graphql"; +import type { + AppLifecycleActionMutationData, + AppLifecycleActionMutationVariables, + AppLifecycleMutationPayload, +} from "../graphql/operation-types"; + +type AppLifecycleAction = "resume" | "suspend" | "uninstall"; + +const unexpectedPayload = (cause: unknown): AppLifecycleMutationPayload => ({ + installation: null, + operation: null, + duplicate: false, + userErrors: [ + { + code: "UNEXPECTED_ERROR", + message: + cause instanceof Error ? cause.message : "Unable to update the app", + }, + ] as ApiGenericUserError[], +}); + +export const useAppLifecycleActions = () => { + const [suspendMutation, suspendState] = useMutation< + AppLifecycleActionMutationData, + AppLifecycleActionMutationVariables + >(APP_SUSPEND_MUTATION); + const [resumeMutation, resumeState] = useMutation< + AppLifecycleActionMutationData, + AppLifecycleActionMutationVariables + >(APP_RESUME_MUTATION); + const [uninstallMutation, uninstallState] = useMutation< + AppLifecycleActionMutationData, + AppLifecycleActionMutationVariables + >(APP_UNINSTALL_MUTATION); + + const runAction = useCallback( + async (action: AppLifecycleAction, installationId: string) => { + const mutation = + action === "suspend" + ? suspendMutation + : action === "resume" + ? resumeMutation + : uninstallMutation; + + try { + const result = await mutation({ + variables: { + input: { + installationId, + clientMutationId: crypto.randomUUID(), + }, + }, + refetchQueries: [APPS_MANAGEMENT_QUERY], + awaitRefetchQueries: true, + }); + const payload = + action === "suspend" + ? result.data?.appsMutation.appSuspend + : action === "resume" + ? result.data?.appsMutation.appResume + : result.data?.appsMutation.appUninstall; + + return payload ?? unexpectedPayload("Missing lifecycle response"); + } catch (cause) { + return unexpectedPayload(cause); + } + }, + [resumeMutation, suspendMutation, uninstallMutation], + ); + + return { + runAction, + loading: + suspendState.loading || resumeState.loading || uninstallState.loading, + error: + suspendState.error ?? + resumeState.error ?? + uninstallState.error ?? + null, + reset: () => { + suspendState.reset(); + resumeState.reset(); + uninstallState.reset(); + }, + }; +}; diff --git a/admin/src/domains/apps/management/hooks/use-apps-management.ts b/admin/src/domains/apps/management/hooks/use-apps-management.ts new file mode 100644 index 000000000..515b15358 --- /dev/null +++ b/admin/src/domains/apps/management/hooks/use-apps-management.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { useQuery } from "@apollo/client/react"; +import { + AppInstallationStatus, + type ApiAppWhereInput, +} from "@/graphql/types"; +import { APPS_MANAGEMENT_QUERY } from "../graphql"; +import type { + AppsManagementQueryData, + AppsManagementQueryVariables, +} from "../graphql/operation-types"; + +const POLLING_STATUSES = new Set([ + AppInstallationStatus.Installing, + AppInstallationStatus.PendingConsent, + AppInstallationStatus.Resuming, + AppInstallationStatus.Suspending, + AppInstallationStatus.Uninstalling, + AppInstallationStatus.Updating, +]); + +export const useAppsManagement = (where?: ApiAppWhereInput) => { + const { + data, + loading, + error, + refetch, + startPolling, + stopPolling, + } = useQuery( + APPS_MANAGEMENT_QUERY, + { + fetchPolicy: "cache-and-network", + variables: { where }, + }, + ); + const apps = useMemo( + () => { + const sourceApps = + data?.appsQuery.apps.edges.map(({ node }) => node) ?? []; + + return [...sourceApps].sort((left, right) => { + const leftRank = + left.installation?.status === AppInstallationStatus.Active + ? 0 + : left.installed + ? 1 + : 2; + const rightRank = + right.installation?.status === AppInstallationStatus.Active + ? 0 + : right.installed + ? 1 + : 2; + + return ( + leftRank - rightRank || + left.displayName.localeCompare(right.displayName) + ); + }); + }, + [data?.appsQuery.apps.edges], + ); + const hasPendingOperation = apps.some(({ installation }) => + installation ? POLLING_STATUSES.has(installation.status) : false, + ); + + useEffect(() => { + if (hasPendingOperation) { + startPolling(2_000); + } else { + stopPolling(); + } + + return stopPolling; + }, [hasPendingOperation, startPolling, stopPolling]); + + return { + apps, + loading, + error: error ?? null, + refetch: async () => { + await refetch(); + }, + }; +}; diff --git a/admin/src/domains/apps/management/hooks/use-install-app.ts b/admin/src/domains/apps/management/hooks/use-install-app.ts new file mode 100644 index 000000000..d1a6f3731 --- /dev/null +++ b/admin/src/domains/apps/management/hooks/use-install-app.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiAppInstallInput, ApiGenericUserError } from "@/graphql/types"; +import { + APP_INSTALL_MUTATION, + APPS_MANAGEMENT_QUERY, +} from "../graphql"; +import type { + AppInstallMutationData, + AppInstallMutationVariables, +} from "../graphql/operation-types"; + +export const useInstallApp = () => { + const [mutate, { loading, error, reset }] = useMutation< + AppInstallMutationData, + AppInstallMutationVariables + >(APP_INSTALL_MUTATION); + + const installApp = useCallback( + async (input: ApiAppInstallInput) => { + try { + const result = await mutate({ + variables: { input }, + refetchQueries: [APPS_MANAGEMENT_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.appsMutation.appInstall; + + return { + installation: payload?.installation ?? null, + operation: payload?.operation ?? null, + duplicate: payload?.duplicate ?? false, + userErrors: payload?.userErrors ?? [], + }; + } catch (cause) { + const message = + cause instanceof Error ? cause.message : "Unable to install app"; + + return { + installation: null, + operation: null, + duplicate: false, + userErrors: [ + { code: "UNEXPECTED_ERROR", message }, + ] as ApiGenericUserError[], + }; + } + }, + [mutate], + ); + + return { + installApp, + loading, + error: error ?? null, + reset, + }; +}; diff --git a/admin/src/domains/apps/management/modals.ts b/admin/src/domains/apps/management/modals.ts new file mode 100644 index 000000000..426fe7c65 --- /dev/null +++ b/admin/src/domains/apps/management/modals.ts @@ -0,0 +1,18 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; + +export const APP_MANAGEMENT_MODAL_TYPE = "app-management"; + +export interface AppManagementModalPayload extends IModalStackPayload { + appCode: string; +} + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [APP_MANAGEMENT_MODAL_TYPE]: AppManagementModalPayload; + } +} + +export const useAppManagementModal = createModalStackHook( + APP_MANAGEMENT_MODAL_TYPE, +); diff --git a/admin/src/domains/apps/management/modals/app-management-modal.tsx b/admin/src/domains/apps/management/modals/app-management-modal.tsx new file mode 100644 index 000000000..1c5c6160c --- /dev/null +++ b/admin/src/domains/apps/management/modals/app-management-modal.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { useMemo } from "react"; +import { + Alert, + App, + Avatar, + Button, + Collapse, + Empty, + Flex, + Skeleton, + Tag, + Timeline, + Typography, +} from "antd"; +import { createStyles } from "antd-style"; +import { + LuActivity, + LuCircleCheck, + LuCodeXml, + LuExternalLink, + LuShieldCheck, +} from "react-icons/lu"; +import { + AppInstallationStatus, + AppLifecycleOperationStatus, + AppRuntimeStatus, +} from "@/graphql/types"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { usePathParams } from "@/registry"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { createAdminAppPath } from "../../runtime/app-route"; +import type { ManagementAppListItem } from "../graphql/operation-types"; +import { + useAppLifecycleActions, + useAppsManagement, + useInstallApp, +} from "../hooks"; +import type { AppManagementModalPayload } from "../modals"; + +const PENDING_STATUSES = new Set([ + AppInstallationStatus.Installing, + AppInstallationStatus.PendingConsent, + AppInstallationStatus.Resuming, + AppInstallationStatus.Suspending, + AppInstallationStatus.Uninstalling, + AppInstallationStatus.Updating, +]); + +const useStyles = createStyles(({ token }) => ({ + summary: { + display: "flex", + alignItems: "center", + gap: token.margin, + }, + avatar: { + flex: "0 0 auto", + borderRadius: token.borderRadiusLG, + }, + summaryCopy: { + display: "flex", + flex: 1, + flexDirection: "column", + minWidth: 0, + }, + appName: { + fontSize: token.fontSizeLG, + lineHeight: token.lineHeightLG, + }, + meta: { + fontSize: token.fontSizeSM, + }, + description: { + margin: `${token.marginXS}px 0 0 !important`, + color: token.colorTextSecondary, + }, + sectionDescription: { + display: "block", + marginBottom: token.margin, + }, + rows: { + display: "flex", + flexDirection: "column", + gap: token.marginSM, + }, + accessRow: { + display: "flex", + alignItems: "flex-start", + gap: token.marginSM, + padding: `${token.paddingSM}px ${token.padding}px`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + }, + accessIcon: { + flex: "0 0 auto", + width: 18, + height: 18, + marginTop: 1, + color: token.colorPrimary, + }, + accessCopy: { + display: "flex", + flex: 1, + flexDirection: "column", + minWidth: 0, + }, + accessName: { + fontSize: token.fontSizeSM, + }, + accessDescription: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + surfaces: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: token.marginSM, + "@media (max-width: 700px)": { + gridTemplateColumns: "1fr", + }, + }, + surface: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: token.marginSM, + padding: `${token.paddingSM}px ${token.padding}px`, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + }, + surfaceName: { + display: "flex", + alignItems: "center", + gap: token.marginXS, + }, + timeline: { + marginTop: token.marginXS, + "& .ant-timeline-item-section": { + minWidth: 0, + }, + "& .ant-timeline-item-content": { + width: "100%", + minWidth: 0, + }, + }, + timelineEntry: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) auto", + alignItems: "start", + gap: token.margin, + width: "100%", + minWidth: 0, + "@media (max-width: 700px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + timelineCopy: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + minWidth: 0, + }, + timelineMeta: { + alignItems: "flex-end", + "@media (max-width: 700px)": { + alignItems: "flex-start", + }, + }, + timelineStatus: { + marginInlineEnd: 0, + }, + timelineDate: { + fontSize: token.fontSizeSM, + whiteSpace: "nowrap", + }, + controlRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: token.marginLG, + }, + controlCopy: { + display: "flex", + flexDirection: "column", + minWidth: 0, + }, + dangerPaper: { + borderColor: token.colorErrorBorder, + }, + dangerRows: { + display: "flex", + flexDirection: "column", + }, + dangerRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: token.marginLG, + minHeight: 56, + "&:not(:last-child)": { + paddingBottom: token.paddingSM, + marginBottom: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + }, + }, +})); + +const formatLabel = (value: string) => + value + .toLowerCase() + .replaceAll("_", " ") + .replaceAll(".", " · ") + .replace(/\b\w/g, (character) => character.toUpperCase()); + +const formatDate = (value?: string | null) => + value + ? new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)) + : "In progress"; + +const getOperationColor = (status: AppLifecycleOperationStatus) => { + if (status === AppLifecycleOperationStatus.Succeeded) return "green"; + if (status === AppLifecycleOperationStatus.Failed) return "red"; + return "blue"; +}; + +function AppSummary({ + app, + openHref, +}: { + app: ManagementAppListItem; + openHref: string | null; +}) { + const { styles } = useStyles(); + const version = app.installation?.installedVersion ?? app.version; + + return ( + +
    + +
    + + {app.displayName} + + + Version {version} + +
    + {openHref ? ( + + ) : null} +
    + + {app.description} + +
    + ); +} + +function AccessAndCapabilities({ app }: { app: ManagementAppListItem }) { + const { styles } = useStyles(); + + return ( + + + + Permissions and platform surfaces declared by this app. + + +
    + {app.permissions.map(({ granted, scope }) => ( +
    + +
    + + {scope} + + + {app.installed + ? granted + ? "Permission granted to this installation." + : "Permission requested but not granted." + : "This permission will be granted during installation."} + +
    + + {granted ? "Granted" : app.installed ? "Not granted" : "Requested"} + +
    + ))} + + {app.permissions.length === 0 ? ( + + This app does not request additional permissions. + + ) : null} + +
    + {[ + { label: "Admin API", enabled: app.graphql.admin }, + { label: "Storefront API", enabled: app.graphql.storefront }, + ].map(({ label, enabled }) => ( +
    + + + {label} + + + {enabled ? "Enabled" : "Disabled"} + +
    + ))} +
    + + {app.capabilities.map((capability) => ( +
    + +
    + + {formatLabel(capability.key)} + + + {capability.operations + .map(({ name }) => formatLabel(name)) + .join(", ") || "No operations declared"} + +
    + {formatLabel(capability.assignmentMode)} +
    + ))} +
    +
    + ); +} + +function AppHistory({ app }: { app: ManagementAppListItem }) { + const { styles } = useStyles(); + const installation = app.installation; + if (!installation) return null; + + const operations = installation.lifecycleOperations.edges.map( + ({ node }) => node, + ); + + return ( + + + + Installation and lifecycle activity for this store. + + + {operations.length > 0 ? ( + ({ + color: getOperationColor(operation.status), + icon: + operation.status === AppLifecycleOperationStatus.Succeeded ? ( + + ) : undefined, + content: ( +
    +
    + + {formatLabel(operation.type)} + + + Version {operation.targetVersion} + + {operation.error ? ( + + {operation.error.message} + + ) : null} +
    +
    + + {formatLabel(operation.status)} + + + {formatDate( + operation.completedAt ?? + operation.startedAt ?? + operation.createdAt, + )} + +
    +
    + ), + }))} + /> + ) : ( + + )} + + + + Installation ID:{" "} + {installation.id} + + + Status: {installation.status} + + + Health: {installation.healthStatus} + + + Updated: {formatDate(installation.updatedAt)} + + + ), + }, + ]} + /> +
    + ); +} + +export const AppManagementModal = () => { + const { styles } = useStyles(); + const { message, modal } = App.useApp(); + const { payload, pop } = useModalStackContext(); + const { getParam } = usePathParams(); + const { appCode } = payload as AppManagementModalPayload; + const { apps, loading, error } = useAppsManagement(); + const { + installApp, + loading: installing, + error: installError, + } = useInstallApp(); + const { + runAction, + loading: lifecycleLoading, + error: lifecycleError, + } = useAppLifecycleActions(); + const app = useMemo( + () => apps.find(({ code }) => code === appCode) ?? null, + [appCode, apps], + ); + + const showErrors = (errors: Array<{ message: string }>) => { + message.error(errors.map(({ message: text }) => text).join("\n")); + }; + + const install = async () => { + if (!app) return; + const result = await installApp({ + appCode: app.code, + clientMutationId: crypto.randomUUID(), + grantedScopes: app.permissions.map(({ scope }) => scope), + }); + if (result.userErrors.length > 0) { + showErrors(result.userErrors); + return; + } + message.success(`${app.displayName} installation started`); + }; + + const confirmInstall = async () => { + if (!app) return; + const permissions = app.permissions.length; + const confirmed = await modal.confirm({ + icon: null, + title: `Install ${app.displayName}?`, + content: + permissions > 0 + ? `The app will receive ${permissions} ${permissions === 1 ? "permission" : "permissions"} shown above.` + : "The app will be installed for this store.", + okText: "Install app", + }); + if (confirmed) { + await install(); + } + }; + + const runLifecycleAction = async ( + action: "resume" | "suspend" | "uninstall", + ) => { + if (!app?.installation) return; + const result = await runAction(action, app.installation.id); + if (result.userErrors.length > 0) { + showErrors(result.userErrors); + return; + } + message.success( + `${app.displayName} ${action === "uninstall" ? "uninstall" : action} started`, + ); + }; + + const uninstall = async () => { + if (!app) return; + const confirmed = await modal.confirm({ + icon: null, + title: `Uninstall ${app.displayName}?`, + content: + "The app will lose access to this store and its installation will be removed.", + okText: "Uninstall app", + okButtonProps: { danger: true }, + }); + if (confirmed) { + await runLifecycleAction("uninstall"); + } + }; + + const suspend = async () => { + if (!app) return; + const confirmed = await modal.confirm({ + icon: null, + title: `Suspend ${app.displayName}?`, + content: + "The app and its integrations will stop working until the app is resumed.", + okText: "Suspend app", + okButtonProps: { danger: true }, + }); + if (confirmed) { + await runLifecycleAction("suspend"); + } + }; + + const installed = Boolean(app?.installed && app.installation); + const pending = app?.installation + ? PENDING_STATUSES.has(app.installation.status) + : false; + const suspended = + app?.installation?.status === AppInstallationStatus.Suspended; + const orgName = getParam("orgName") ?? ""; + const storeName = getParam("storeName") ?? ""; + const openHref = + app && + app.installation?.status === AppInstallationStatus.Active + ? createAdminAppPath({ + orgName, + storeName, + appCode: app.code, + }) + : null; + + return ( + + } + name="app-management" + > + {error ? ( + + ) : null} + {installError || lifecycleError ? ( + + ) : null} + + {loading && !app ? ( + + + + ) : app ? ( + <> + + + {installed ? : null} + + {installed ? ( + <> + {suspended ? ( + + +
    +
    + Resume app + + Restore this app and its store access. + +
    + +
    +
    + ) : null} + + +
    + {!suspended ? ( +
    +
    + + Suspend app + + + Temporarily disable this app without uninstalling it. + +
    + +
    + ) : null} +
    +
    + + Uninstall app + + + Remove this app and revoke its access to the store. + +
    + +
    +
    +
    + + ) : ( + + + + )} + + ) : ( + + + + )} +
    + ); +}; diff --git a/admin/src/domains/apps/register.tsx b/admin/src/domains/apps/register.tsx new file mode 100644 index 000000000..c8cdf94e7 --- /dev/null +++ b/admin/src/domains/apps/register.tsx @@ -0,0 +1,26 @@ +import dynamic from "next/dynamic"; +import { registerModule } from "@/registry"; +import { LuBlocks } from "react-icons/lu"; + +registerModule({ + key: "admin-app-runtime", + domain: "system", + items: [ + { + key: "admin-app-page", + path: "/:orgName/:storeName/apps/:appCode{/*appPath}", + component: dynamic(() => import("./runtime/app-runtime-page")), + }, + ], +}); + +registerModule({ + key: "admin-apps", + domain: "system", + sidebar: { + label: "Apps", + icon: , + order: 0, + }, + items: [], +}); diff --git a/admin/src/domains/apps/runtime/admin-app-extension-point.tsx b/admin/src/domains/apps/runtime/admin-app-extension-point.tsx new file mode 100644 index 000000000..aef8e41dc --- /dev/null +++ b/admin/src/domains/apps/runtime/admin-app-extension-point.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { + Suspense, + use, + useSyncExternalStore, +} from "react"; +import { Skeleton } from "antd"; +import type { + AdminAppExtensionComponent, + AdminExtensionPointMap, +} from "../sdk"; +import { AppRuntimeBoundary } from "./app-runtime-boundary"; +import { loadAdminAppRemoteModule } from "./federation/load-remote-module"; +import { adminAppRegistry } from "./registry/app-registry"; +import { adminAppExtensionRegistry } from "./registry/extension-registry"; + +interface ContributionProps { + appCode: string; + module: string; + context: AdminExtensionPointMap[keyof AdminExtensionPointMap]; +} + +function Contribution({ appCode, module, context }: ContributionProps) { + const app = adminAppRegistry.get(appCode); + if (!app) return null; + const loadedRemote = use(loadAdminAppRemoteModule(app.descriptor, module)); + const Component = loadedRemote.default as AdminAppExtensionComponent; + + return ( + + }> + + + + ); +} + +export function AdminAppExtensionPoint< + TPoint extends keyof AdminExtensionPointMap, +>({ + point, + context, +}: { + point: TPoint; + context: AdminExtensionPointMap[TPoint]; +}) { + useSyncExternalStore( + adminAppExtensionRegistry.subscribe.bind(adminAppExtensionRegistry), + adminAppExtensionRegistry.getSnapshot, + adminAppExtensionRegistry.getSnapshot, + ); + const contributions = adminAppExtensionRegistry.forPoint(point); + + return contributions.map((contribution) => ( + + )); +} diff --git a/admin/src/domains/apps/runtime/admin-apps-host-provider.tsx b/admin/src/domains/apps/runtime/admin-apps-host-provider.tsx new file mode 100644 index 000000000..8d934eff6 --- /dev/null +++ b/admin/src/domains/apps/runtime/admin-apps-host-provider.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { App } from "antd"; +import { useApolloClient } from "@apollo/client/react"; +import { useRouter } from "next/navigation"; +import { + useCallback, + useEffect, + useRef, + type ReactNode, +} from "react"; +import { usePathParams } from "@/registry/path-params-context"; +import type { SidebarItem } from "@/registry/registry"; +import { useDynamicSidebarStore } from "@/layouts/app/components/sidebar/dynamic-sidebar-store"; +import { AdminDataGrid, AdminAppPage, AdminAppModalLayout } from "../sdk/ui"; +import { createAdminAppSdk } from "../sdk/create-app-sdk"; +import { + createAdminAppModalApi, + registerAdminAppModals, + unregisterAdminAppModals, +} from "../sdk/modal-api"; +import type { + AdminAppPageLayoutProps, + AdminAppSdk, + AdminAppUiApi, +} from "../sdk"; +import { AdminAppIcon } from "./app-icon"; +import { AppRuntimeScope } from "./app-runtime-scope"; +import { createAdminAppPath } from "./app-route"; +import type { AdminAppUiDescriptor } from "./descriptor-schema"; +import { InstalledAppsRuntimeSync } from "./installed-apps-runtime-sync"; +import { adminAppRegistry, type ActiveAdminApp } from "./registry/app-registry"; +import { adminAppExtensionRegistry } from "./registry/extension-registry"; +import { adminAppNavigationRegistry } from "./registry/navigation-registry"; + +const NO_FALLBACK_DESCRIPTORS: readonly AdminAppUiDescriptor[] = []; + +function createAppUi(descriptor: AdminAppUiDescriptor): AdminAppUiApi { + function AppPage(props: AdminAppPageLayoutProps) { + const appIdentityPage = props.title === undefined; + return ( + + ) : undefined) + } + title={props.title ?? descriptor.displayName} + /> + ); + } + + return { + AppPage, + ModalLayout: AdminAppModalLayout, + DataGrid: AdminDataGrid, + }; +} + +function supportsCurrentSdk(range: string): boolean { + return range === "1.0.0" || range.startsWith("^1."); +} + +function ownerFor(descriptor: AdminAppUiDescriptor): string { + return `app:${descriptor.appCode}@${descriptor.version}:${descriptor.installationId}`; +} + +function descriptorSignature(descriptor: AdminAppUiDescriptor): string { + return JSON.stringify(descriptor); +} + +export function AdminAppsHostProvider({ children }: { children: ReactNode }) { + const router = useRouter(); + const client = useApolloClient(); + const { notification } = App.useApp(); + const path = usePathParams(); + const setSidebarChildren = useDynamicSidebarStore( + (state) => state.setChildren, + ); + const clearSidebarChildren = useDynamicSidebarStore( + (state) => state.clearChildren, + ); + const activeRef = useRef([]); + const disposeActiveApps = useCallback(() => { + activeRef.current.forEach(({ scope }) => { + unregisterAdminAppModals(scope.owner); + scope.dispose(); + }); + activeRef.current = []; + adminAppRegistry.replace([]); + adminAppExtensionRegistry.replace([]); + adminAppNavigationRegistry.replace([]); + }, []); + + const activateDescriptors = useCallback( + (descriptors: AdminAppUiDescriptor[]) => { + const orgName = path.getParam("orgName") ?? ""; + const storeName = path.getParam("storeName") ?? ""; + const previousByAppCode = new Map( + activeRef.current.map((app) => [app.descriptor.appCode, app]), + ); + + const active = descriptors + .filter((descriptor) => supportsCurrentSdk(descriptor.sdkVersionRange)) + .map((descriptor): ActiveAdminApp => { + const previous = previousByAppCode.get(descriptor.appCode); + const canReuse = + previous && + previous.sdk.context.orgName === orgName && + previous.sdk.context.storeName === storeName && + descriptorSignature(previous.descriptor) === + descriptorSignature(descriptor); + + if (canReuse) { + previousByAppCode.delete(descriptor.appCode); + return previous; + } + + if (previous) { + unregisterAdminAppModals(previous.scope.owner); + previous.scope.dispose(); + previousByAppCode.delete(descriptor.appCode); + } + + const owner = ownerFor(descriptor); + const scope = new AppRuntimeScope(owner); + const modalApi = createAdminAppModalApi(descriptor, owner); + const storePrefix = `/${encodeURIComponent(orgName)}/${encodeURIComponent( + storeName, + )}`; + + const sdk: AdminAppSdk = createAdminAppSdk({ + identity: { + code: descriptor.appCode, + version: descriptor.version, + installationId: descriptor.installationId, + displayName: descriptor.displayName, + description: descriptor.description, + icon: descriptor.icon, + }, + context: { + organizationId: orgName, + storeId: storeName, + orgName, + storeName, + grantedScopes: descriptor.grantedScopes, + }, + scope, + modals: modalApi, + navigation: { + openAppPath: (appPath) => + router.push(createAdminAppPath({ + orgName, + storeName, + appCode: descriptor.appCode, + appPath, + })), + replaceAppPath: (appPath) => + router.replace(createAdminAppPath({ + orgName, + storeName, + appCode: descriptor.appCode, + appPath, + })), + openCorePath: (corePath) => { + if ( + !corePath.startsWith("/") || + corePath.startsWith("//") || + /^[a-z]+:/i.test(corePath) + ) { + throw new Error(`Core Admin path "${corePath}" is not allowed`); + } + router.push(`${storePrefix}${corePath}`); + }, + }, + graphql: { + query: async (document, variables) => { + const result = await client.query({ + query: document, + variables: variables as Record, + fetchPolicy: "network-only", + }); + return result.data; + }, + mutate: async (document, variables) => { + const result = await client.mutate({ + mutation: document, + variables: variables as Record, + }); + if (!result.data) { + throw new Error("Admin App GraphQL mutation returned no data"); + } + return result.data; + }, + }, + notifications: { + success: (message, description) => + notification.success({ message, description }), + error: (message, description) => + notification.error({ message, description }), + info: (message, description) => + notification.info({ message, description }), + warning: (message, description) => + notification.warning({ message, description }), + }, + ui: createAppUi(descriptor), + }); + + registerAdminAppModals(descriptor, owner, () => sdk); + return { descriptor, scope, sdk }; + }); + + previousByAppCode.forEach(({ scope }) => { + unregisterAdminAppModals(scope.owner); + scope.dispose(); + }); + + activeRef.current = active; + adminAppRegistry.replace(active); + adminAppExtensionRegistry.replace( + active.flatMap(({ descriptor, scope }) => + descriptor.extensions.map((extension) => ({ + owner: scope.owner, + appCode: descriptor.appCode, + descriptor: extension, + })), + ), + ); + adminAppNavigationRegistry.replace( + active.flatMap(({ descriptor, scope }) => + descriptor.navigation.map((item) => ({ + owner: scope.owner, + appCode: descriptor.appCode, + displayName: descriptor.displayName, + item, + })), + ), + ); + const navigationItems: SidebarItem[] = active + .map(({ descriptor }) => ({ + key: `admin-app-${descriptor.appCode}`, + label: descriptor.displayName, + iconKey: descriptor.icon.url, + icon: ( + + ), + path: `/:orgName/:storeName/apps/${encodeURIComponent( + descriptor.appCode, + )}`, + activePaths: [ + `/:orgName/:storeName/apps/${encodeURIComponent( + descriptor.appCode, + )}{/*appPath}`, + ], + })) + .sort((left, right) => left.label.localeCompare(right.label)) + .map((item, index) => ({ + ...item, + order: index, + })); + setSidebarChildren("admin-apps", navigationItems); + }, + [ + client, + notification, + path, + router, + setSidebarChildren, + ], + ); + + useEffect( + () => () => { + disposeActiveApps(); + clearSidebarChildren("admin-apps"); + }, + [clearSidebarChildren, disposeActiveApps], + ); + + return ( + <> + + {children} + + ); +} diff --git a/admin/src/domains/apps/runtime/app-icon.tsx b/admin/src/domains/apps/runtime/app-icon.tsx new file mode 100644 index 000000000..9afde8659 --- /dev/null +++ b/admin/src/domains/apps/runtime/app-icon.tsx @@ -0,0 +1,38 @@ +import { createStyles } from "antd-style"; + +const useStyles = createStyles(({ token }) => ({ + icon: { + borderRadius: token.borderRadiusSM, + display: "block", + flex: "none", + objectFit: "contain", + }, +})); + +export interface AdminAppIconMetadata { + readonly url: string; + readonly alt: string; +} + +export function AdminAppIcon({ + icon, + size, + decorative = false, +}: { + icon: AdminAppIconMetadata; + size: number; + decorative?: boolean; +}) { + const { styles } = useStyles(); + + return ( + {decorative + ); +} diff --git a/admin/src/domains/apps/runtime/app-route.ts b/admin/src/domains/apps/runtime/app-route.ts new file mode 100644 index 000000000..9753b680c --- /dev/null +++ b/admin/src/domains/apps/runtime/app-route.ts @@ -0,0 +1,24 @@ +interface AdminAppPathInput { + orgName: string; + storeName: string; + appCode: string; + appPath?: string; +} + +export function createAdminAppPath({ + orgName, + storeName, + appCode, + appPath = "", +}: AdminAppPathInput): string { + const prefix = `/${encodeURIComponent(orgName)}/${encodeURIComponent( + storeName, + )}/apps/${encodeURIComponent(appCode)}`; + const suffix = appPath + .split("/") + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join("/"); + + return suffix ? `${prefix}/${suffix}` : prefix; +} diff --git a/admin/src/domains/apps/runtime/app-runtime-boundary.tsx b/admin/src/domains/apps/runtime/app-runtime-boundary.tsx new file mode 100644 index 000000000..7110a8fb3 --- /dev/null +++ b/admin/src/domains/apps/runtime/app-runtime-boundary.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Alert, Button } from "antd"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface Props { + appCode: string; + children: ReactNode; +} + +interface State { + error: Error | null; +} + +export class AppRuntimeBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error("[AdminApps] Remote render failed", { + appCode: this.props.appCode, + error, + componentStack: info.componentStack, + }); + } + + render() { + if (this.state.error) { + return ( + this.setState({ error: null })}> + Retry + + } + /> + ); + } + return this.props.children; + } +} + diff --git a/admin/src/domains/apps/runtime/app-runtime-page.tsx b/admin/src/domains/apps/runtime/app-runtime-page.tsx new file mode 100644 index 000000000..4a42de98b --- /dev/null +++ b/admin/src/domains/apps/runtime/app-runtime-page.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { Alert, Skeleton } from "antd"; +import { useRouter } from "next/navigation"; +import { + Suspense, + use, + useEffect, + useSyncExternalStore, +} from "react"; +import type { ModulePageProps } from "@/registry"; +import type { AdminAppPageComponent } from "../sdk"; +import { + AdminAppLoadingContent, + AdminAppPage, +} from "../sdk/ui"; +import { createAdminAppPath } from "./app-route"; +import { AppRuntimeBoundary } from "./app-runtime-boundary"; +import { loadAdminAppRemoteModule } from "./federation/load-remote-module"; +import { adminAppRegistry } from "./registry/app-registry"; +import type { ActiveAdminApp } from "./registry/app-registry"; + +function AppPageLoader({ active }: { active?: ActiveAdminApp }) { + if (active) { + return ( + + + + ); + } + + return ( + + } + title={} + > + + + ); +} + +function RemotePageMount({ + active, + appPath, + searchParams, +}: { + active: ActiveAdminApp; + appPath: string; + searchParams: Record; +}) { + const pageDeclaration = active.descriptor.page; + if (!pageDeclaration) return null; + const loadedRemote = use( + loadAdminAppRemoteModule(active.descriptor, pageDeclaration.module), + ); + const Component = loadedRemote.default as AdminAppPageComponent; + + return ( + + entry[1] !== undefined, + ), + ), + }} + /> + ); +} + +export default function AppRuntimePage({ + pathParams, + searchParams = {}, +}: ModulePageProps) { + const router = useRouter(); + const registryRevision = useSyncExternalStore( + adminAppRegistry.subscribe.bind(adminAppRegistry), + adminAppRegistry.getSnapshot, + adminAppRegistry.getSnapshot, + ); + const appCode = + typeof pathParams.appCode === "string" ? pathParams.appCode : ""; + const active = adminAppRegistry.get(appCode); + const appPath = Array.isArray(pathParams.appPath) + ? pathParams.appPath.join("/") + : typeof pathParams.appPath === "string" + ? pathParams.appPath + : ""; + const defaultPath = active?.descriptor.page?.defaultPath + ?.split("/") + .filter(Boolean) + .join("/"); + const shouldOpenDefaultPath = Boolean( + active && !appPath && defaultPath, + ); + + useEffect(() => { + if (!active || !shouldOpenDefaultPath || !defaultPath) return; + const orgName = + typeof pathParams.orgName === "string" ? pathParams.orgName : ""; + const storeName = + typeof pathParams.storeName === "string" ? pathParams.storeName : ""; + router.replace( + createAdminAppPath({ + orgName, + storeName, + appCode: active.descriptor.appCode, + appPath: defaultPath, + }), + ); + }, [ + active, + defaultPath, + pathParams.orgName, + pathParams.storeName, + router, + shouldOpenDefaultPath, + ]); + + if (!active) { + if (registryRevision === 0) { + return ; + } + + return ( + + ); + } + + if (!active.descriptor.page) { + return ( + + ); + } + + if (shouldOpenDefaultPath) { + return ; + } + + return ( + + }> + + + + ); +} diff --git a/admin/src/domains/apps/runtime/app-runtime-scope.ts b/admin/src/domains/apps/runtime/app-runtime-scope.ts new file mode 100644 index 000000000..8748ea150 --- /dev/null +++ b/admin/src/domains/apps/runtime/app-runtime-scope.ts @@ -0,0 +1,27 @@ +export class AppRuntimeDisposedError extends Error { + constructor(owner: string) { + super(`Admin App runtime scope "${owner}" has been disposed`); + this.name = "AppRuntimeDisposedError"; + } +} + +export class AppRuntimeScope { + private active = true; + + constructor(readonly owner: string) {} + + assertActive(): void { + if (!this.active) { + throw new AppRuntimeDisposedError(this.owner); + } + } + + dispose(): void { + this.active = false; + } + + get isActive(): boolean { + return this.active; + } +} + diff --git a/admin/src/domains/apps/runtime/descriptor-schema.ts b/admin/src/domains/apps/runtime/descriptor-schema.ts new file mode 100644 index 000000000..7f8cac6fe --- /dev/null +++ b/admin/src/domains/apps/runtime/descriptor-schema.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +const navigationSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + path: z.string(), + order: z.number().int().default(100), +}); + +const modalSchema = z.object({ + id: z.string().min(1), + module: z.string().min(1), + confirmOnDirtyClose: z.boolean().default(false), + closeConfirmMessage: z.string().optional(), + requiredScopes: z.array(z.string()).default([]), +}); + +const extensionSchema = z.object({ + id: z.string().min(1), + point: z.string().min(1), + module: z.string().min(1), + priority: z.number().int().default(100), + requiredScopes: z.array(z.string()).default([]), + conditions: z.record(z.string(), z.unknown()).optional(), +}); + +export const adminAppUiDescriptorSchema = z.object({ + installationId: z.string().min(1), + appCode: z.string().regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/), + displayName: z.string().min(1), + description: z.string().min(1), + icon: z.object({ + url: z.string().min(1), + alt: z.string().min(1), + }), + version: z.string().min(1), + sdkVersionRange: z.string().min(1), + remote: z.object({ + name: z.string().min(1), + manifestUrl: z.string().min(1), + contentHash: z.string().min(1), + }), + page: z + .object({ + module: z.string().min(1), + defaultPath: z.string().optional(), + }) + .nullable() + .optional(), + navigation: z.array(navigationSchema).default([]), + modals: z.array(modalSchema).default([]), + extensions: z.array(extensionSchema).default([]), + grantedScopes: z.array(z.string()).default([]), +}); + +export type AdminAppUiDescriptor = z.infer; +export type AdminAppModalDescriptor = AdminAppUiDescriptor["modals"][number]; +export type AdminAppExtensionDescriptor = + AdminAppUiDescriptor["extensions"][number]; + +export function parseAdminAppUiDescriptors( + input: unknown, +): AdminAppUiDescriptor[] { + return z.array(adminAppUiDescriptorSchema).parse(input); +} diff --git a/admin/src/domains/apps/runtime/federation/errors.ts b/admin/src/domains/apps/runtime/federation/errors.ts new file mode 100644 index 000000000..f87223540 --- /dev/null +++ b/admin/src/domains/apps/runtime/federation/errors.ts @@ -0,0 +1,11 @@ +export class AdminAppRemoteLoadError extends Error { + constructor( + readonly appCode: string, + readonly module: string, + cause?: unknown, + ) { + super(`Unable to load Admin App module "${appCode}:${module}"`, { cause }); + this.name = "AdminAppRemoteLoadError"; + } +} + diff --git a/admin/src/domains/apps/runtime/federation/load-remote-module.ts b/admin/src/domains/apps/runtime/federation/load-remote-module.ts new file mode 100644 index 000000000..aaf7686ec --- /dev/null +++ b/admin/src/domains/apps/runtime/federation/load-remote-module.ts @@ -0,0 +1,102 @@ +import type { ComponentType } from "react"; +import type { AdminAppUiDescriptor } from "../descriptor-schema"; +import { AdminAppRemoteLoadError } from "./errors"; +import { getFederationRuntime } from "./runtime"; +import { registerAdminAppRemote } from "./register-remote"; + +type RemoteModule = { default: ComponentType }; +type LocalModuleLoader = () => Promise; + +const localModules = new Map(); +const loadedModules = new Map>(); +const REMOTE_LOAD_TIMEOUT_MS = 15_000; +const REMOTE_LOAD_ATTEMPTS = 2; + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => reject(new Error(`Remote load timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + promise.then( + (value) => { + window.clearTimeout(timeout); + resolve(value); + }, + (error) => { + window.clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +export function registerLocalAdminAppModule( + remoteName: string, + module: string, + loader: () => Promise<{ default: ComponentType }>, +): void { + localModules.set( + `${remoteName}:${module}`, + loader as unknown as LocalModuleLoader, + ); +} + +export function loadAdminAppRemoteModule( + descriptor: AdminAppUiDescriptor, + module: string, +): Promise { + const cacheKey = `${descriptor.remote.name}:${module}`; + const current = loadedModules.get(cacheKey); + if (current) { + return current; + } + + const promise = (async () => { + try { + const localLoader = localModules.get(cacheKey); + if (localLoader) { + return await localLoader(); + } + + let lastError: unknown; + for (let attempt = 1; attempt <= REMOTE_LOAD_ATTEMPTS; attempt += 1) { + try { + await registerAdminAppRemote(descriptor); + const runtime = await getFederationRuntime(); + const exposed = module.replace(/^\.\//, ""); + const loaded = await withTimeout( + runtime.loadRemote( + `${descriptor.remote.name}/${exposed}`, + ), + REMOTE_LOAD_TIMEOUT_MS, + ); + if (!loaded?.default) { + throw new Error("Remote module has no default component export"); + } + console.info("[AdminApps] Remote module loaded", { + appCode: descriptor.appCode, + version: descriptor.version, + module, + attempt, + }); + return loaded; + } catch (error) { + lastError = error; + console.warn("[AdminApps] Remote module load attempt failed", { + appCode: descriptor.appCode, + version: descriptor.version, + module, + attempt, + }); + } + } + throw lastError; + } catch (error) { + throw new AdminAppRemoteLoadError(descriptor.appCode, module, error); + } + })(); + + loadedModules.set(cacheKey, promise); + return promise; +} diff --git a/admin/src/domains/apps/runtime/federation/register-remote.ts b/admin/src/domains/apps/runtime/federation/register-remote.ts new file mode 100644 index 000000000..43bf257b4 --- /dev/null +++ b/admin/src/domains/apps/runtime/federation/register-remote.ts @@ -0,0 +1,80 @@ +import type { AdminAppUiDescriptor } from "../descriptor-schema"; +import { getFederationRuntime } from "./runtime"; + +const registered = new Map(); +const verifiedManifests = new Set(); + +function assertAllowedManifestUrl(manifestUrl: string): void { + const url = new URL(manifestUrl, window.location.origin); + const allowedOrigins = new Set([ + window.location.origin, + ...String(process.env.NEXT_PUBLIC_ADMIN_APP_ASSET_ORIGINS ?? "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean), + ]); + + if (!allowedOrigins.has(url.origin)) { + throw new Error(`Admin App asset origin "${url.origin}" is not allowed`); + } +} + +async function assertManifestIntegrity( + manifestUrl: string, + expectedHash: string, +): Promise { + if (!/^[a-f0-9]{64}$/i.test(expectedHash)) { + return; + } + const cacheKey = `${manifestUrl}:${expectedHash}`; + if (verifiedManifests.has(cacheKey)) { + return; + } + + const response = await fetch(manifestUrl, { + credentials: "omit", + cache: "force-cache", + }); + if (!response.ok) { + throw new Error(`Unable to verify Admin App manifest (${response.status})`); + } + const bytes = await response.arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actualHash = [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); + if (actualHash !== expectedHash.toLowerCase()) { + throw new Error("Admin App manifest integrity check failed"); + } + verifiedManifests.add(cacheKey); +} + +export async function registerAdminAppRemote( + descriptor: AdminAppUiDescriptor, +): Promise { + if (descriptor.remote.manifestUrl.startsWith("local:")) { + return; + } + + assertAllowedManifestUrl(descriptor.remote.manifestUrl); + await assertManifestIntegrity( + descriptor.remote.manifestUrl, + descriptor.remote.contentHash, + ); + const current = registered.get(descriptor.remote.name); + if (current === descriptor.remote.manifestUrl) { + return; + } + + const runtime = await getFederationRuntime(); + runtime.registerRemotes( + [ + { + name: descriptor.remote.name, + entry: descriptor.remote.manifestUrl, + }, + ], + { force: Boolean(current) }, + ); + registered.set(descriptor.remote.name, descriptor.remote.manifestUrl); +} diff --git a/admin/src/domains/apps/runtime/federation/runtime.ts b/admin/src/domains/apps/runtime/federation/runtime.ts new file mode 100644 index 000000000..66866e3e8 --- /dev/null +++ b/admin/src/domains/apps/runtime/federation/runtime.ts @@ -0,0 +1,51 @@ +"use client"; + +import * as React from "react"; +import * as ReactDom from "react-dom"; +import * as Antd from "antd"; +import * as AntdStyle from "antd-style"; +import * as AntdCssInJs from "@ant-design/cssinjs"; +import * as ApolloClient from "@apollo/client"; +import * as AgGridReact from "ag-grid-react"; +import * as AgGridCommunity from "ag-grid-community"; +import type { ModuleFederation } from "@module-federation/enhanced/runtime"; + +let runtimePromise: Promise | null = null; + +const shared = ( + version: string, + module: Record, +): { + version: string; + scope: string; + lib: () => Record; + shareConfig: { singleton: true; requiredVersion: string }; +} => ({ + version, + scope: "default", + lib: () => module, + shareConfig: { singleton: true, requiredVersion: version }, +}); + +export function getFederationRuntime(): Promise { + if (!runtimePromise) { + runtimePromise = import("@module-federation/enhanced/runtime").then( + ({ createInstance }) => + createInstance({ + name: "shopana_admin_host", + remotes: [], + shared: { + react: shared("19.2.0", React), + "react-dom": shared("19.2.0", ReactDom), + antd: shared("6.0.1", Antd), + "antd-style": shared("3.7.1", AntdStyle), + "@ant-design/cssinjs": shared("2.0.1", AntdCssInJs), + "@apollo/client": shared("4.0.12", ApolloClient), + "ag-grid-react": shared("34.3.1", AgGridReact), + "ag-grid-community": shared("34.3.1", AgGridCommunity), + }, + }), + ); + } + return runtimePromise; +} diff --git a/admin/src/domains/apps/runtime/installed-apps-runtime-sync.tsx b/admin/src/domains/apps/runtime/installed-apps-runtime-sync.tsx new file mode 100644 index 000000000..5a1e59cb9 --- /dev/null +++ b/admin/src/domains/apps/runtime/installed-apps-runtime-sync.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { useEffect, useMemo } from "react"; +import { AppInstallationStatus } from "@/graphql/types"; +import { + ADMIN_UI_APPS_QUERY, + APPS_MANAGEMENT_QUERY, +} from "../management/graphql"; +import type { + AdminUiAppsQueryData, + AppsManagementQueryData, +} from "../management/graphql/operation-types"; +import { + parseAdminAppUiDescriptors, + type AdminAppUiDescriptor, +} from "./descriptor-schema"; +import { getLocalAdminApp } from "./local-app-registry"; + +interface Props { + fallbackDescriptors: readonly AdminAppUiDescriptor[]; + onDescriptors: (descriptors: AdminAppUiDescriptor[]) => void; +} + +export function InstalledAppsRuntimeSync({ + fallbackDescriptors, + onDescriptors, +}: Props) { + const discoveryEnabled = + process.env.NEXT_PUBLIC_ADMIN_APPS_DISCOVERY === "true"; + const { + data: discoveredData, + loading: discoveredLoading, + } = useQuery( + ADMIN_UI_APPS_QUERY, + { + skip: !discoveryEnabled, + fetchPolicy: "cache-and-network", + }, + ); + const { + data: managementData, + loading: managementLoading, + } = useQuery( + APPS_MANAGEMENT_QUERY, + { + skip: discoveryEnabled, + fetchPolicy: "cache-and-network", + }, + ); + const localDescriptors = useMemo(() => { + if (discoveryEnabled) return []; + + return (managementData?.appsQuery.apps.edges ?? []).flatMap( + ({ node: app }) => { + const registration = getLocalAdminApp(app.code); + const installation = app.installation; + if ( + !registration || + !installation || + installation.status !== AppInstallationStatus.Active + ) { + return []; + } + + return [ + { + installationId: installation.id, + appCode: app.code, + displayName: app.displayName, + description: app.description, + icon: app.icon, + version: installation.installedVersion, + sdkVersionRange: "^1.0.0", + remote: { + name: registration.remoteName, + manifestUrl: `local:${app.code}`, + contentHash: "local", + }, + page: { + module: registration.pageModule, + defaultPath: registration.defaultPath, + }, + navigation: [], + modals: + registration.modals?.map( + ({ + load: _load, + confirmOnDirtyClose = false, + requiredScopes = [], + ...modal + }) => ({ + ...modal, + confirmOnDirtyClose, + requiredScopes, + }), + ) ?? [], + extensions: [], + grantedScopes: app.permissions + .filter(({ granted }) => granted) + .map(({ scope }) => scope), + }, + ]; + }, + ); + }, [ + discoveryEnabled, + managementData?.appsQuery.apps.edges, + ]); + + const source = discoveryEnabled + ? discoveredData?.appsQuery.adminUiApps + : localDescriptors; + const loading = discoveryEnabled + ? discoveredLoading + : managementLoading; + + useEffect(() => { + if ( + loading && + (source === undefined || source.length === 0) && + fallbackDescriptors.length === 0 + ) { + return; + } + const descriptors = source ?? fallbackDescriptors; + try { + onDescriptors(parseAdminAppUiDescriptors(descriptors)); + } catch (error) { + console.error("[AdminApps] Invalid UI descriptor response", error); + onDescriptors([...fallbackDescriptors]); + } + }, [fallbackDescriptors, loading, onDescriptors, source]); + + return null; +} diff --git a/admin/src/domains/apps/runtime/local-app-registry.ts b/admin/src/domains/apps/runtime/local-app-registry.ts new file mode 100644 index 000000000..f615f682a --- /dev/null +++ b/admin/src/domains/apps/runtime/local-app-registry.ts @@ -0,0 +1,49 @@ +import type { ComponentType } from "react"; +import type { AdminAppPageProps } from "../sdk"; +import { registerLocalAdminAppModule } from "./federation/load-remote-module"; + +export interface LocalAdminAppModalRegistration { + id: string; + module: string; + confirmOnDirtyClose?: boolean; + closeConfirmMessage?: string; + requiredScopes?: string[]; + load: () => Promise<{ + default: ComponentType; + }>; +} + +export interface LocalAdminAppRegistration { + appCode: string; + remoteName: string; + pageModule: string; + defaultPath?: string; + modals?: LocalAdminAppModalRegistration[]; +} + +const registrations = new Map(); + +export function registerLocalAdminApp( + registration: LocalAdminAppRegistration, + loadPage: () => Promise<{ default: ComponentType }>, +): void { + registrations.set(registration.appCode, registration); + registerLocalAdminAppModule( + registration.remoteName, + registration.pageModule, + loadPage, + ); + registration.modals?.forEach((modal) => { + registerLocalAdminAppModule( + registration.remoteName, + modal.module, + modal.load, + ); + }); +} + +export function getLocalAdminApp( + appCode: string, +): LocalAdminAppRegistration | undefined { + return registrations.get(appCode); +} diff --git a/admin/src/domains/apps/runtime/registry/app-registry.ts b/admin/src/domains/apps/runtime/registry/app-registry.ts new file mode 100644 index 000000000..428407678 --- /dev/null +++ b/admin/src/domains/apps/runtime/registry/app-registry.ts @@ -0,0 +1,42 @@ +import type { AdminAppSdk } from "../../sdk"; +import type { AdminAppUiDescriptor } from "../descriptor-schema"; +import type { AppRuntimeScope } from "../app-runtime-scope"; + +export interface ActiveAdminApp { + descriptor: AdminAppUiDescriptor; + scope: AppRuntimeScope; + sdk: AdminAppSdk; +} + +class AdminAppRegistry { + private apps = new Map(); + private listeners = new Set<() => void>(); + private revision = 0; + + replace(apps: readonly ActiveAdminApp[]): void { + this.apps = new Map(apps.map((app) => [app.descriptor.appCode, app])); + this.revision += 1; + this.emit(); + } + + get(appCode: string): ActiveAdminApp | undefined { + return this.apps.get(appCode); + } + + getAll(): ActiveAdminApp[] { + return [...this.apps.values()]; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getSnapshot = (): number => this.revision; + + private emit(): void { + this.listeners.forEach((listener) => listener()); + } +} + +export const adminAppRegistry = new AdminAppRegistry(); diff --git a/admin/src/domains/apps/runtime/registry/extension-registry.ts b/admin/src/domains/apps/runtime/registry/extension-registry.ts new file mode 100644 index 000000000..8a6468a50 --- /dev/null +++ b/admin/src/domains/apps/runtime/registry/extension-registry.ts @@ -0,0 +1,39 @@ +import type { AdminAppExtensionDescriptor } from "../descriptor-schema"; + +export interface RegisteredAdminAppExtension { + owner: string; + appCode: string; + descriptor: AdminAppExtensionDescriptor; +} + +class AdminAppExtensionRegistry { + private extensions: RegisteredAdminAppExtension[] = []; + private listeners = new Set<() => void>(); + private revision = 0; + + replace(extensions: readonly RegisteredAdminAppExtension[]): void { + this.extensions = [...extensions].sort( + (left, right) => + left.descriptor.priority - right.descriptor.priority || + left.appCode.localeCompare(right.appCode) || + left.descriptor.id.localeCompare(right.descriptor.id), + ); + this.revision += 1; + this.listeners.forEach((listener) => listener()); + } + + forPoint(point: string): RegisteredAdminAppExtension[] { + return this.extensions.filter( + (extension) => extension.descriptor.point === point, + ); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getSnapshot = (): number => this.revision; +} + +export const adminAppExtensionRegistry = new AdminAppExtensionRegistry(); diff --git a/admin/src/domains/apps/runtime/registry/navigation-registry.ts b/admin/src/domains/apps/runtime/registry/navigation-registry.ts new file mode 100644 index 000000000..d8a1517b0 --- /dev/null +++ b/admin/src/domains/apps/runtime/registry/navigation-registry.ts @@ -0,0 +1,28 @@ +import type { AdminAppUiDescriptor } from "../descriptor-schema"; + +export interface AdminAppNavigationContribution { + owner: string; + appCode: string; + displayName: string; + item: AdminAppUiDescriptor["navigation"][number]; +} + +class AdminAppNavigationRegistry { + private contributions: AdminAppNavigationContribution[] = []; + + replace(contributions: readonly AdminAppNavigationContribution[]): void { + this.contributions = [...contributions].sort( + (left, right) => + left.item.order - right.item.order || + left.appCode.localeCompare(right.appCode) || + left.item.id.localeCompare(right.item.id), + ); + } + + getAll(): AdminAppNavigationContribution[] { + return [...this.contributions]; + } +} + +export const adminAppNavigationRegistry = new AdminAppNavigationRegistry(); + diff --git a/admin/src/domains/apps/sdk/contracts.ts b/admin/src/domains/apps/sdk/contracts.ts new file mode 100644 index 000000000..c48d87c10 --- /dev/null +++ b/admin/src/domains/apps/sdk/contracts.ts @@ -0,0 +1,193 @@ +import type { ComponentType, ReactNode } from "react"; +import type { + ColDef, + GetRowIdParams, + GridOptions, +} from "ag-grid-community"; + +export const ADMIN_APP_SDK_VERSION = "1.0.0"; + +export type AdminModalResult = + | { status: "submitted"; data: TResult } + | { status: "cancelled"; reason: "closed" | "owner-disposed" }; + +export interface CoreModalContractMap { + "catalog.product.details": { + input: { productId: string; mode?: "view" | "edit" }; + result: void; + }; + "catalog.product.picker": { + input: { multiple?: boolean; selectedProductIds?: string[] }; + result: { productIds: string[] }; + }; + "catalog.variant.picker": { + input: { productId?: string; multiple?: boolean }; + result: { variantIds: string[] }; + }; + "media.file.picker": { + input: { multiple?: boolean; accept?: string[] }; + result: { fileIds: string[] }; + }; +} + +export interface AdminAppModalApi { + openApp( + modalId: string, + payload: TPayload, + ): Promise>; + openCore( + modal: TKey, + input: CoreModalContractMap[TKey]["input"], + ): Promise>; + closeCurrent(result?: TResult): void; + setCurrentDirty(dirty: boolean): void; +} + +export interface AdminAppNavigationApi { + openAppPath(path: string): void; + replaceAppPath(path: string): void; + openCorePath(path: string): void; +} + +export interface TypedDocumentNodeLike { + readonly kind: "Document"; + readonly __apiType?: (variables: TVariables) => TData; +} + +export interface AdminAppGraphqlApi { + query( + document: TypedDocumentNodeLike, + variables: TVariables, + ): Promise; + mutate( + document: TypedDocumentNodeLike, + variables: TVariables, + ): Promise; +} + +export interface AdminAppNotificationsApi { + success(message: string, description?: string): void; + error(message: string, description?: string): void; + info(message: string, description?: string): void; + warning(message: string, description?: string): void; +} + +export interface AdminAppPageLayoutProps { + title?: ReactNode; + description?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; + onBack?: () => void; + children: ReactNode; +} + +export interface AdminAppModalLayoutProps { + title: string; + children: ReactNode; + actions?: ReactNode; +} + +export type AdminDataGridProps = Omit< + GridOptions, + "theme" | "rowData" | "columnDefs" | "getRowId" +> & { + rowData: readonly TRowData[]; + columnDefs: readonly ColDef[]; + getRowId: (params: GetRowIdParams) => string; + height?: number | string; +}; + +export interface AdminAppUiApi { + AppPage: ComponentType; + ModalLayout: ComponentType; + DataGrid: (props: AdminDataGridProps) => ReactNode; +} + +export interface AdminAppSdk { + readonly app: { + code: string; + version: string; + installationId: string; + displayName: string; + description: string; + icon: { + url: string; + alt: string; + }; + }; + readonly context: { + organizationId: string; + storeId: string; + orgName: string; + storeName: string; + grantedScopes: readonly string[]; + }; + readonly modals: AdminAppModalApi; + readonly navigation: AdminAppNavigationApi; + readonly graphql: AdminAppGraphqlApi; + readonly notifications: AdminAppNotificationsApi; + readonly ui: AdminAppUiApi; +} + +export interface AdminAppPageProps { + sdk: AdminAppSdk; + route: { + appPath: string; + searchParams: Readonly>; + }; +} + +export interface AdminAppModalProps { + sdk: AdminAppSdk; + payload: TPayload; +} + +export interface OrdersListToolbarContext { + readonly selectedOrderIds: readonly string[]; +} + +export interface OrderRowActionsContext { + readonly orderId: string; +} + +export interface OrderDetailsContext { + readonly orderId: string; +} + +export interface FulfillmentContext { + readonly orderId: string; + readonly fulfillmentId: string; +} + +export interface OrderShippingContext { + readonly orderId: string; + readonly fulfillmentId?: string; +} + +export interface OrderPaymentContext { + readonly orderId: string; + readonly paymentId?: string; +} + +export interface AdminExtensionPointMap { + "orders.list.toolbar.actions": OrdersListToolbarContext; + "orders.list.row.actions": OrderRowActionsContext; + "orders.details.header.actions": OrderDetailsContext; + "orders.details.primary.after": OrderDetailsContext; + "orders.details.sidebar.after": OrderDetailsContext; + "orders.details.fulfillment.actions": FulfillmentContext; + "orders.details.fulfillment.after": FulfillmentContext; + "orders.details.shipping.after": OrderShippingContext; + "orders.details.payment.after": OrderPaymentContext; +} + +export interface AdminExtensionProps< + TPoint extends keyof AdminExtensionPointMap = keyof AdminExtensionPointMap, +> { + sdk: AdminAppSdk; + context: AdminExtensionPointMap[TPoint]; +} + +export type AdminAppPageComponent = ComponentType; +export type AdminAppModalComponent = ComponentType; +export type AdminAppExtensionComponent = ComponentType; diff --git a/admin/src/domains/apps/sdk/core-modals.ts b/admin/src/domains/apps/sdk/core-modals.ts new file mode 100644 index 000000000..1babf22f1 --- /dev/null +++ b/admin/src/domains/apps/sdk/core-modals.ts @@ -0,0 +1,9 @@ +import type { CoreModalContractMap } from "./contracts"; + +export const CORE_MODAL_TYPES: Record = { + "catalog.product.details": "product", + "catalog.product.picker": "product-picker", + "catalog.variant.picker": "variant-picker", + "media.file.picker": "media-picker", +}; + diff --git a/admin/src/domains/apps/sdk/create-app-sdk.ts b/admin/src/domains/apps/sdk/create-app-sdk.ts new file mode 100644 index 000000000..7ca29def0 --- /dev/null +++ b/admin/src/domains/apps/sdk/create-app-sdk.ts @@ -0,0 +1,112 @@ +import type { DocumentNode } from "graphql"; +import type { + AdminAppGraphqlApi, + AdminAppModalApi, + AdminAppNavigationApi, + AdminAppNotificationsApi, + AdminAppSdk, + AdminAppUiApi, + TypedDocumentNodeLike, +} from "./contracts"; +import type { AppRuntimeScope } from "../runtime/app-runtime-scope"; + +export interface CreateAdminAppSdkOptions { + identity: AdminAppSdk["app"]; + context: AdminAppSdk["context"]; + scope: AppRuntimeScope; + modals: AdminAppModalApi; + navigation: AdminAppNavigationApi; + graphql: { + query( + document: DocumentNode, + variables: TVariables, + ): Promise; + mutate( + document: DocumentNode, + variables: TVariables, + ): Promise; + }; + notifications: AdminAppNotificationsApi; + ui: AdminAppUiApi; +} + +export function createAdminAppSdk({ + identity, + context, + scope, + modals, + navigation, + graphql, + notifications, + ui, +}: CreateAdminAppSdkOptions): AdminAppSdk { + const guard = ( + operation: (...args: TArgs) => TResult, + ) => { + return (...args: TArgs): TResult => { + scope.assertActive(); + return operation(...args); + }; + }; + + const guardEffect = ( + operation: (...args: TArgs) => void, + ) => { + return (...args: TArgs): void => { + if (!scope.isActive) return; + operation(...args); + }; + }; + + const guardedGraphql: AdminAppGraphqlApi = { + query: guard( + ( + document: TypedDocumentNodeLike, + variables: TVariables, + ) => + graphql.query(document as DocumentNode, variables) as Promise, + ), + mutate: guard( + ( + document: TypedDocumentNodeLike, + variables: TVariables, + ) => + graphql.mutate(document as DocumentNode, variables) as Promise, + ), + }; + + const sdk: AdminAppSdk = { + app: Object.freeze({ ...identity }), + context: Object.freeze({ + ...context, + grantedScopes: Object.freeze([...context.grantedScopes]), + }), + modals: { + openApp: (modalId: string, payload: TPayload) => { + scope.assertActive(); + return modals.openApp(modalId, payload); + }, + openCore: (modal, input) => { + scope.assertActive(); + return modals.openCore(modal, input); + }, + closeCurrent: guard(modals.closeCurrent), + setCurrentDirty: guard(modals.setCurrentDirty), + }, + navigation: { + openAppPath: guardEffect(navigation.openAppPath), + replaceAppPath: guardEffect(navigation.replaceAppPath), + openCorePath: guardEffect(navigation.openCorePath), + }, + graphql: guardedGraphql, + notifications: { + success: guardEffect(notifications.success), + error: guardEffect(notifications.error), + info: guardEffect(notifications.info), + warning: guardEffect(notifications.warning), + }, + ui, + }; + + return Object.freeze(sdk); +} diff --git a/admin/src/domains/apps/sdk/index.ts b/admin/src/domains/apps/sdk/index.ts new file mode 100644 index 000000000..edf936fb2 --- /dev/null +++ b/admin/src/domains/apps/sdk/index.ts @@ -0,0 +1,3 @@ +export * from "./contracts"; +export { ADMIN_APP_SDK_VERSION } from "./contracts"; + diff --git a/admin/src/domains/apps/sdk/modal-api.tsx b/admin/src/domains/apps/sdk/modal-api.tsx new file mode 100644 index 000000000..6685e3a01 --- /dev/null +++ b/admin/src/domains/apps/sdk/modal-api.tsx @@ -0,0 +1,199 @@ +import type { ComponentType } from "react"; +import { lazy } from "react"; +import { + modalStackRegistry, + useModalStackContext, + useModalStackStore, +} from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; +import type { AdminAppUiDescriptor } from "../runtime/descriptor-schema"; +import { loadAdminAppRemoteModule } from "../runtime/federation/load-remote-module"; +import type { + AdminAppModalApi, + AdminAppModalProps, + AdminAppSdk, + AdminModalResult, + CoreModalContractMap, +} from "./contracts"; +import { CORE_MODAL_TYPES } from "./core-modals"; + +interface AppModalPayload extends IModalStackPayload { + __adminAppPayload: unknown; + __adminAppResolve: (result: AdminModalResult) => void; +} + +const appModalType = ( + owner: string, + modalId: string, +): string => `${owner}:${modalId}`; + +export function registerAdminAppModals( + descriptor: AdminAppUiDescriptor, + owner: string, + getSdk: () => AdminAppSdk, +): void { + descriptor.modals.forEach((modalDescriptor) => { + const RemoteModal = lazy(async () => { + const remoteModule = await loadAdminAppRemoteModule( + descriptor, + modalDescriptor.module, + ); + return { + default: remoteModule.default as ComponentType, + }; + }); + + function ScopedAdminAppModal() { + const { payload } = useModalStackContext(); + const appPayload = payload as AppModalPayload; + return ( + + ); + } + + modalStackRegistry.register({ + type: appModalType(owner, modalDescriptor.id), + owner, + component: ScopedAdminAppModal, + confirmOnDirtyClose: modalDescriptor.confirmOnDirtyClose, + closeConfirmMessage: modalDescriptor.closeConfirmMessage, + }); + }); +} + +export function unregisterAdminAppModals(owner: string): void { + useModalStackStore.getState().closeByOwner(owner); + modalStackRegistry.unregisterByOwner(owner); +} + +export function createAdminAppModalApi( + descriptor: AdminAppUiDescriptor, + owner: string, +): AdminAppModalApi { + const resolveAndPop = (result: AdminModalResult): void => { + const store = useModalStackStore.getState(); + const current = store.peek(); + if (!current || current.owner !== owner) { + return; + } + const payload = current.payload as AppModalPayload; + payload.__adminAppResolve(result as AdminModalResult); + store.pop(current.uuid); + }; + + return { + openApp: (modalId: string, payload: TPayload) => { + const definition = descriptor.modals.find((modal) => modal.id === modalId); + if (!definition) { + return Promise.reject( + new Error(`App modal "${descriptor.appCode}:${modalId}" is not declared`), + ); + } + + const missingScope = definition.requiredScopes.find( + (scope) => !descriptor.grantedScopes.includes(scope), + ); + if (missingScope) { + return Promise.reject( + new Error(`App modal "${modalId}" requires scope "${missingScope}"`), + ); + } + + return new Promise>((resolve) => { + let settled = false; + const settle = (result: AdminModalResult) => { + if (settled) return; + settled = true; + resolve(result as AdminModalResult); + }; + useModalStackStore.getState().push( + appModalType(owner, modalId), + { + __adminAppPayload: payload, + __adminAppResolve: settle, + }, + { + owner, + onRemoved: (reason) => + settle({ status: "cancelled", reason }), + }, + ); + }); + }, + openCore: ( + modal: TKey, + input: CoreModalContractMap[TKey]["input"], + ) => { + return new Promise< + AdminModalResult + >((resolve) => { + let settled = false; + const settle = ( + result: AdminModalResult, + ) => { + if (settled) return; + settled = true; + resolve(result); + }; + const basePayload: IModalStackPayload = {}; + + if (modal === "catalog.product.details") { + const details = input as CoreModalContractMap["catalog.product.details"]["input"]; + Object.assign(basePayload, { + entityId: details.productId, + mode: details.mode ?? "view", + }); + } else if (modal === "catalog.product.picker") { + const picker = input as CoreModalContractMap["catalog.product.picker"]["input"]; + Object.assign(basePayload, { + selectionMode: picker.multiple === false ? "single" : "multi", + initialSelection: picker.selectedProductIds ?? [], + onConfirm: (_entities: unknown[], productIds: string[]) => + settle({ status: "submitted", data: { productIds } } as never), + }); + } else if (modal === "catalog.variant.picker") { + const picker = input as CoreModalContractMap["catalog.variant.picker"]["input"]; + Object.assign(basePayload, { + selectionMode: picker.multiple === false ? "single" : "multi", + queryMeta: { productId: picker.productId }, + onConfirm: (_entities: unknown[], variantIds: string[]) => + settle({ status: "submitted", data: { variantIds } } as never), + }); + } else { + const picker = input as CoreModalContractMap["media.file.picker"]["input"]; + Object.assign(basePayload, { + selectionMode: picker.multiple === false ? "single" : "multi", + accept: picker.accept?.join(","), + onConfirm: (files: Array<{ id: string }>) => + settle({ + status: "submitted", + data: { fileIds: files.map((file) => file.id) }, + } as never), + }); + } + + useModalStackStore.getState().push( + CORE_MODAL_TYPES[modal], + basePayload, + { + owner, + onRemoved: (reason) => + settle({ status: "cancelled", reason }), + }, + ); + }); + }, + closeCurrent: (result?: TResult) => + resolveAndPop({ status: "submitted", data: result }), + setCurrentDirty: (dirty: boolean) => { + const store = useModalStackStore.getState(); + const current = store.peek(); + if (current?.owner === owner) { + store.setDirty(current.uuid, dirty); + } + }, + }; +} diff --git a/admin/src/domains/apps/sdk/ui/admin-app-loading-content.tsx b/admin/src/domains/apps/sdk/ui/admin-app-loading-content.tsx new file mode 100644 index 000000000..fcd8ff3c6 --- /dev/null +++ b/admin/src/domains/apps/sdk/ui/admin-app-loading-content.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Flex, Skeleton, Spin } from "antd"; +import { createStyles } from "antd-style"; +import { Paper } from "@/ui-kit/paper"; + +const useStyles = createStyles(({ token }) => ({ + loading: { + flex: 1, + minHeight: 360, + position: "relative", + }, + skeletons: { + display: "flex", + flexDirection: "column", + gap: token.padding, + }, + indicator: { + inset: 0, + pointerEvents: "none", + position: "absolute", + }, +})); + +export function AdminAppLoadingContent() { + const { styles } = useStyles(); + + return ( +
    +
    + + + + + + + + + +
    + + + +
    + ); +} diff --git a/admin/src/domains/apps/sdk/ui/admin-app-page.tsx b/admin/src/domains/apps/sdk/ui/admin-app-page.tsx new file mode 100644 index 000000000..07c1b8a7e --- /dev/null +++ b/admin/src/domains/apps/sdk/ui/admin-app-page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Typography } from "antd"; +import { createStyles } from "antd-style"; +import { DataLayout } from "@/layouts/data"; +import type { AdminAppPageLayoutProps } from "../contracts"; + +const useStyles = createStyles(({ token }) => ({ + content: { + display: "flex", + flexDirection: "column", + gap: token.padding, + paddingBottom: token.padding, + }, + description: { + display: "block", + }, + title: { + alignItems: "center", + display: "inline-flex", + gap: token.paddingSM, + }, +})); + +export function AdminAppPage({ + title, + description, + icon, + actions, + onBack, + children, +}: AdminAppPageLayoutProps) { + const { styles } = useStyles(); + + return ( + + {icon} + {title} + + } + > +
    + {description ? ( + + {description} + + ) : null} + {children} +
    +
    + ); +} diff --git a/admin/src/domains/apps/sdk/ui/admin-data-grid.tsx b/admin/src/domains/apps/sdk/ui/admin-data-grid.tsx new file mode 100644 index 000000000..85c7ecb94 --- /dev/null +++ b/admin/src/domains/apps/sdk/ui/admin-data-grid.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { AgGridReact } from "ag-grid-react"; +import { useAgGridTheme } from "@/hooks"; +import type { AdminDataGridProps } from "../contracts"; + +export function AdminDataGrid({ + height = 420, + rowData, + columnDefs, + ...props +}: AdminDataGridProps) { + const theme = useAgGridTheme(); + + return ( +
    + + {...props} + theme={theme} + rowData={[...rowData]} + columnDefs={[...columnDefs]} + /> +
    + ); +} + diff --git a/admin/src/domains/apps/sdk/ui/admin-modal-layout.tsx b/admin/src/domains/apps/sdk/ui/admin-modal-layout.tsx new file mode 100644 index 000000000..100cbe4ec --- /dev/null +++ b/admin/src/domains/apps/sdk/ui/admin-modal-layout.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ModalHeader, ModalLayout } from "@/layouts/modals"; +import { useModalStackContext } from "@/layouts/modals"; +import type { AdminAppModalLayoutProps } from "../contracts"; + +export function AdminAppModalLayout({ + title, + children, + actions, +}: AdminAppModalLayoutProps) { + const { pop } = useModalStackContext(); + + return ( + + } + > + {children} + + ); +} diff --git a/admin/src/domains/apps/sdk/ui/index.ts b/admin/src/domains/apps/sdk/ui/index.ts new file mode 100644 index 000000000..5feccc056 --- /dev/null +++ b/admin/src/domains/apps/sdk/ui/index.ts @@ -0,0 +1,4 @@ +export { AdminAppPage } from "./admin-app-page"; +export { AdminAppLoadingContent } from "./admin-app-loading-content"; +export { AdminAppModalLayout } from "./admin-modal-layout"; +export { AdminDataGrid } from "./admin-data-grid"; diff --git a/admin/src/domains/apps/shopana-headless/module-federation.config.ts b/admin/src/domains/apps/shopana-headless/module-federation.config.ts new file mode 100644 index 000000000..3a5c7cf60 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/module-federation.config.ts @@ -0,0 +1,29 @@ +const singleton = { + singleton: true, + requiredVersion: false, +} as const; + +const config = { + name: "shopana_headless_admin", + filename: "remoteEntry.js", + exposes: { + "./Page": "./src/page.tsx", + "./CreateStorefrontModal": + "./src/modals/create-storefront-modal.tsx", + "./RenameStorefrontModal": "./src/modals/rename-storefront-modal.tsx", + "./DisconnectStorefrontModal": + "./src/modals/disconnect-storefront-modal.tsx", + }, + shared: { + react: singleton, + "react-dom": singleton, + antd: singleton, + "antd-style": singleton, + "@ant-design/cssinjs": singleton, + "@apollo/client": singleton, + "ag-grid-react": singleton, + "ag-grid-community": singleton, + }, +} as const; + +export default config; diff --git a/admin/src/domains/apps/shopana-headless/register.ts b/admin/src/domains/apps/shopana-headless/register.ts new file mode 100644 index 000000000..3a5f2fc62 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/register.ts @@ -0,0 +1,37 @@ +import { registerLocalAdminApp } from "../runtime/local-app-registry"; +import { + CREATE_STOREFRONT_MODAL_ID, + DISCONNECT_STOREFRONT_MODAL_ID, + RENAME_STOREFRONT_MODAL_ID, +} from "./src/modals"; + +registerLocalAdminApp( + { + appCode: "shopana-headless", + remoteName: "shopana_headless_admin", + pageModule: "./Page", + defaultPath: "storefronts", + modals: [ + { + id: CREATE_STOREFRONT_MODAL_ID, + module: "./CreateStorefrontModal", + confirmOnDirtyClose: true, + closeConfirmMessage: "Discard storefront connection settings?", + load: () => import("./src/modals/create-storefront-modal"), + }, + { + id: RENAME_STOREFRONT_MODAL_ID, + module: "./RenameStorefrontModal", + confirmOnDirtyClose: true, + closeConfirmMessage: "Discard storefront name changes?", + load: () => import("./src/modals/rename-storefront-modal"), + }, + { + id: DISCONNECT_STOREFRONT_MODAL_ID, + module: "./DisconnectStorefrontModal", + load: () => import("./src/modals/disconnect-storefront-modal"), + }, + ], + }, + () => import("./src/page"), +); diff --git a/packages/checkout-sdk/src/dto.js b/admin/src/domains/apps/shopana-headless/src/extensions/index.ts similarity index 100% rename from packages/checkout-sdk/src/dto.js rename to admin/src/domains/apps/shopana-headless/src/extensions/index.ts diff --git a/admin/src/domains/apps/shopana-headless/src/graphql/fragments.ts b/admin/src/domains/apps/shopana-headless/src/graphql/fragments.ts new file mode 100644 index 000000000..2099e3922 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/graphql/fragments.ts @@ -0,0 +1,27 @@ +import { gql } from "@apollo/client"; + +export const HEADLESS_STOREFRONT_FIELDS = gql` + fragment HeadlessStorefrontFields on HeadlessStorefrontConnection { + id + displayName + status + publicAccessToken + createdAt + updatedAt + storefrontAccessPolicy { + permissions + revision + updatedAt + } + storefrontCredentials { + id + kind + status + label + tokenHint + createdAt + lastUsedAt + revokedAt + } + } +`; diff --git a/admin/src/domains/apps/shopana-headless/src/graphql/index.ts b/admin/src/domains/apps/shopana-headless/src/graphql/index.ts new file mode 100644 index 000000000..0c2adeab0 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/graphql/index.ts @@ -0,0 +1,2 @@ +export * from "./mutations"; +export * from "./queries"; diff --git a/admin/src/domains/apps/shopana-headless/src/graphql/mutations.ts b/admin/src/domains/apps/shopana-headless/src/graphql/mutations.ts new file mode 100644 index 000000000..5ae5fb9a3 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/graphql/mutations.ts @@ -0,0 +1,178 @@ +import { gql } from "@apollo/client"; +import type { TypedDocumentNodeLike } from "@shopana/admin-app-sdk"; +import { HEADLESS_STOREFRONT_FIELDS } from "./fragments"; +import type { + HeadlessStorefrontActionMutationData, + HeadlessStorefrontActionMutationVariables, + HeadlessStorefrontCreateMutationData, + HeadlessStorefrontCreateMutationVariables, + HeadlessStorefrontUpdateMutationData, + HeadlessStorefrontUpdateMutationVariables, + StorefrontAccessPolicyUpdateMutationData, + StorefrontAccessPolicyUpdateMutationVariables, + StorefrontPrivateCredentialCreateMutationData, + StorefrontPrivateCredentialCreateMutationVariables, +} from "./operation-types"; + +export const HEADLESS_STOREFRONT_CREATE_MUTATION = gql` + mutation HeadlessStorefrontCreate($input: HeadlessStorefrontCreateInput!) { + headlessAppMutation { + headlessStorefrontCreate(input: $input) { + connection { + ...HeadlessStorefrontFields + } + initialStorefrontCredentials { + publicAccessToken + privateAccessToken + } + userErrors { + code + field + message + } + } + } + } + ${HEADLESS_STOREFRONT_FIELDS} +` as TypedDocumentNodeLike< + HeadlessStorefrontCreateMutationData, + HeadlessStorefrontCreateMutationVariables +>; + +export const HEADLESS_STOREFRONT_UPDATE_MUTATION = gql` + mutation HeadlessStorefrontUpdate($input: HeadlessStorefrontUpdateInput!) { + headlessAppMutation { + headlessStorefrontUpdate(input: $input) { + connection { + ...HeadlessStorefrontFields + } + userErrors { + code + field + message + } + } + } + } + ${HEADLESS_STOREFRONT_FIELDS} +` as TypedDocumentNodeLike< + HeadlessStorefrontUpdateMutationData, + HeadlessStorefrontUpdateMutationVariables +>; + +export const HEADLESS_STOREFRONT_SUSPEND_MUTATION = gql` + mutation HeadlessStorefrontSuspend($input: HeadlessStorefrontActionInput!) { + headlessAppMutation { + headlessStorefrontSuspend(input: $input) { + connection { + ...HeadlessStorefrontFields + } + userErrors { + code + field + message + } + } + } + } + ${HEADLESS_STOREFRONT_FIELDS} +` as TypedDocumentNodeLike< + HeadlessStorefrontActionMutationData, + HeadlessStorefrontActionMutationVariables +>; + +export const HEADLESS_STOREFRONT_RESUME_MUTATION = gql` + mutation HeadlessStorefrontResume($input: HeadlessStorefrontActionInput!) { + headlessAppMutation { + headlessStorefrontResume(input: $input) { + connection { + ...HeadlessStorefrontFields + } + userErrors { + code + field + message + } + } + } + } + ${HEADLESS_STOREFRONT_FIELDS} +` as TypedDocumentNodeLike< + HeadlessStorefrontActionMutationData, + HeadlessStorefrontActionMutationVariables +>; + +export const HEADLESS_STOREFRONT_DISCONNECT_MUTATION = gql` + mutation HeadlessStorefrontDisconnect($input: HeadlessStorefrontActionInput!) { + headlessAppMutation { + headlessStorefrontDisconnect(input: $input) { + connection { + ...HeadlessStorefrontFields + } + userErrors { + code + field + message + } + } + } + } + ${HEADLESS_STOREFRONT_FIELDS} +` as TypedDocumentNodeLike< + HeadlessStorefrontActionMutationData, + HeadlessStorefrontActionMutationVariables +>; + +export const STOREFRONT_ACCESS_POLICY_UPDATE_MUTATION = gql` + mutation StorefrontAccessPolicyUpdate( + $input: StorefrontAccessPolicyUpdateInput! + ) { + headlessAppMutation { + storefrontAccessPolicyUpdate(input: $input) { + policy { + permissions + revision + updatedAt + } + userErrors { + code + field + message + } + } + } + } +` as TypedDocumentNodeLike< + StorefrontAccessPolicyUpdateMutationData, + StorefrontAccessPolicyUpdateMutationVariables +>; + +export const STOREFRONT_PRIVATE_CREDENTIAL_CREATE_MUTATION = gql` + mutation StorefrontPrivateCredentialCreate( + $input: StorefrontPrivateCredentialCreateInput! + ) { + headlessAppMutation { + storefrontPrivateCredentialCreate(input: $input) { + credential { + id + kind + status + label + tokenHint + createdAt + lastUsedAt + revokedAt + } + privateAccessToken + userErrors { + code + field + message + } + } + } + } +` as TypedDocumentNodeLike< + StorefrontPrivateCredentialCreateMutationData, + StorefrontPrivateCredentialCreateMutationVariables +>; diff --git a/admin/src/domains/apps/shopana-headless/src/graphql/operation-types.ts b/admin/src/domains/apps/shopana-headless/src/graphql/operation-types.ts new file mode 100644 index 000000000..a54d56c3c --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/graphql/operation-types.ts @@ -0,0 +1,115 @@ +import type { + ApiGenericUserError, + ApiHeadlessStorefrontConnection, + ApiHeadlessStorefrontCreateInput, + ApiHeadlessStorefrontPermissionDefinition, + ApiHeadlessStorefrontUpdateInput, + ApiHeadlessStorefrontActionInput, + ApiStorefrontAccessPolicy, + ApiStorefrontAccessPolicyUpdateInput, + ApiStorefrontCredential, + ApiStorefrontPrivateCredentialCreateInput, +} from "@/graphql/types"; + +export type HeadlessStorefront = Pick< + ApiHeadlessStorefrontConnection, + | "id" + | "displayName" + | "status" + | "publicAccessToken" + | "createdAt" + | "updatedAt" +> & { + storefrontAccessPolicy: ApiStorefrontAccessPolicy | null; + storefrontCredentials: ApiStorefrontCredential[]; +}; + +export interface HeadlessStorefrontsQueryData { + headlessAppQuery: { + headlessStorefrontConnections: HeadlessStorefront[]; + headlessStorefrontPermissionCatalog: ApiHeadlessStorefrontPermissionDefinition[]; + headlessStorefrontDefaultPermissions: string[]; + }; +} + +export type HeadlessStorefrontsQueryVariables = Record; + +export interface HeadlessStorefrontQueryData { + headlessAppQuery: { + headlessStorefrontConnection: HeadlessStorefront | null; + headlessStorefrontPermissionCatalog: ApiHeadlessStorefrontPermissionDefinition[]; + }; +} + +export interface HeadlessStorefrontQueryVariables { + id: string; +} + +interface HeadlessStorefrontMutationPayload { + connection: HeadlessStorefront | null; + userErrors: ApiGenericUserError[]; +} + +export interface HeadlessStorefrontCreateMutationData { + headlessAppMutation: { + headlessStorefrontCreate: HeadlessStorefrontMutationPayload & { + initialStorefrontCredentials: { + publicAccessToken: string; + privateAccessToken: string; + } | null; + }; + }; +} + +export interface HeadlessStorefrontCreateMutationVariables { + input: ApiHeadlessStorefrontCreateInput; +} + +export interface HeadlessStorefrontUpdateMutationData { + headlessAppMutation: { + headlessStorefrontUpdate: HeadlessStorefrontMutationPayload; + }; +} + +export interface HeadlessStorefrontUpdateMutationVariables { + input: ApiHeadlessStorefrontUpdateInput; +} + +export interface HeadlessStorefrontActionMutationData { + headlessAppMutation: { + headlessStorefrontSuspend?: HeadlessStorefrontMutationPayload; + headlessStorefrontResume?: HeadlessStorefrontMutationPayload; + headlessStorefrontDisconnect?: HeadlessStorefrontMutationPayload; + }; +} + +export interface HeadlessStorefrontActionMutationVariables { + input: ApiHeadlessStorefrontActionInput; +} + +export interface StorefrontAccessPolicyUpdateMutationData { + headlessAppMutation: { + storefrontAccessPolicyUpdate: { + policy: ApiStorefrontAccessPolicy | null; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface StorefrontAccessPolicyUpdateMutationVariables { + input: ApiStorefrontAccessPolicyUpdateInput; +} + +export interface StorefrontPrivateCredentialCreateMutationData { + headlessAppMutation: { + storefrontPrivateCredentialCreate: { + credential: ApiStorefrontCredential | null; + privateAccessToken: string | null; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface StorefrontPrivateCredentialCreateMutationVariables { + input: ApiStorefrontPrivateCredentialCreateInput; +} diff --git a/admin/src/domains/apps/shopana-headless/src/graphql/queries.ts b/admin/src/domains/apps/shopana-headless/src/graphql/queries.ts new file mode 100644 index 000000000..37198f624 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/graphql/queries.ts @@ -0,0 +1,54 @@ +import { gql } from "@apollo/client"; +import type { TypedDocumentNodeLike } from "@shopana/admin-app-sdk"; +import { HEADLESS_STOREFRONT_FIELDS } from "./fragments"; +import type { + HeadlessStorefrontQueryData, + HeadlessStorefrontQueryVariables, + HeadlessStorefrontsQueryData, + HeadlessStorefrontsQueryVariables, +} from "./operation-types"; + +export const HEADLESS_STOREFRONTS_QUERY = gql` + query HeadlessStorefronts { + headlessAppQuery { + headlessStorefrontPermissionCatalog { + handle + resource + action + label + description + risk + } + headlessStorefrontDefaultPermissions + headlessStorefrontConnections { + ...HeadlessStorefrontFields + } + } + } + ${HEADLESS_STOREFRONT_FIELDS} +` as TypedDocumentNodeLike< + HeadlessStorefrontsQueryData, + HeadlessStorefrontsQueryVariables +>; + +export const HEADLESS_STOREFRONT_QUERY = gql` + query HeadlessStorefront($id: ID!) { + headlessAppQuery { + headlessStorefrontConnection(id: $id) { + ...HeadlessStorefrontFields + } + headlessStorefrontPermissionCatalog { + handle + resource + action + label + description + risk + } + } + } + ${HEADLESS_STOREFRONT_FIELDS} +` as TypedDocumentNodeLike< + HeadlessStorefrontQueryData, + HeadlessStorefrontQueryVariables +>; diff --git a/admin/src/domains/apps/shopana-headless/src/hooks/index.ts b/admin/src/domains/apps/shopana-headless/src/hooks/index.ts new file mode 100644 index 000000000..aa3f636b7 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./use-headless-storefront"; +export * from "./use-headless-storefront-actions"; +export * from "./use-headless-storefronts"; diff --git a/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefront-actions.ts b/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefront-actions.ts new file mode 100644 index 000000000..631a1bea9 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefront-actions.ts @@ -0,0 +1,168 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { AdminAppSdk } from "@shopana/admin-app-sdk"; +import type { ApiGenericUserError } from "@/graphql/types"; +import { + HEADLESS_STOREFRONT_CREATE_MUTATION, + HEADLESS_STOREFRONT_DISCONNECT_MUTATION, + HEADLESS_STOREFRONT_RESUME_MUTATION, + HEADLESS_STOREFRONT_SUSPEND_MUTATION, + HEADLESS_STOREFRONT_UPDATE_MUTATION, + STOREFRONT_ACCESS_POLICY_UPDATE_MUTATION, + STOREFRONT_PRIVATE_CREDENTIAL_CREATE_MUTATION, +} from "../graphql"; + +const mutationId = () => crypto.randomUUID(); + +function assertSuccess(userErrors: ApiGenericUserError[]) { + if (userErrors.length > 0) { + throw new Error(userErrors.map(({ message }) => message).join("\n")); + } +} + +export function useHeadlessStorefrontActions(sdk: AdminAppSdk) { + const [loading, setLoading] = useState(false); + + const run = useCallback(async (operation: () => Promise) => { + setLoading(true); + try { + return await operation(); + } finally { + setLoading(false); + } + }, []); + + const createStorefront = useCallback( + (displayName: string, permissions: string[]) => + run(async () => { + const data = await sdk.graphql.mutate( + HEADLESS_STOREFRONT_CREATE_MUTATION, + { + input: { + displayName, + permissions, + clientMutationId: mutationId(), + }, + }, + ); + const payload = data.headlessAppMutation.headlessStorefrontCreate; + assertSuccess(payload.userErrors); + return payload; + }), + [run, sdk], + ); + + const renameStorefront = useCallback( + (connectionId: string, displayName: string) => + run(async () => { + const data = await sdk.graphql.mutate( + HEADLESS_STOREFRONT_UPDATE_MUTATION, + { + input: { + connectionId, + displayName, + clientMutationId: mutationId(), + }, + }, + ); + const payload = data.headlessAppMutation.headlessStorefrontUpdate; + assertSuccess(payload.userErrors); + return payload.connection; + }), + [run, sdk], + ); + + const setSuspended = useCallback( + (connectionId: string, suspended: boolean) => + run(async () => { + const document = suspended + ? HEADLESS_STOREFRONT_SUSPEND_MUTATION + : HEADLESS_STOREFRONT_RESUME_MUTATION; + const data = await sdk.graphql.mutate(document, { + input: { connectionId, clientMutationId: mutationId() }, + }); + const payload = suspended + ? data.headlessAppMutation.headlessStorefrontSuspend + : data.headlessAppMutation.headlessStorefrontResume; + if (!payload) throw new Error("The storefront action did not complete."); + assertSuccess(payload.userErrors); + return payload.connection; + }), + [run, sdk], + ); + + const disconnectStorefront = useCallback( + (connectionId: string) => + run(async () => { + const data = await sdk.graphql.mutate( + HEADLESS_STOREFRONT_DISCONNECT_MUTATION, + { + input: { connectionId, clientMutationId: mutationId() }, + }, + ); + const payload = data.headlessAppMutation.headlessStorefrontDisconnect; + if (!payload) throw new Error("The storefront was not disconnected."); + assertSuccess(payload.userErrors); + return payload.connection; + }), + [run, sdk], + ); + + const updatePolicy = useCallback( + ( + connectionId: string, + permissions: string[], + expectedRevision: number, + ) => + run(async () => { + const data = await sdk.graphql.mutate( + STOREFRONT_ACCESS_POLICY_UPDATE_MUTATION, + { + input: { + connectionId, + permissions, + expectedRevision, + clientMutationId: mutationId(), + }, + }, + ); + const payload = + data.headlessAppMutation.storefrontAccessPolicyUpdate; + assertSuccess(payload.userErrors); + return payload.policy; + }), + [run, sdk], + ); + + const createPrivateCredential = useCallback( + (connectionId: string, label: string) => + run(async () => { + const data = await sdk.graphql.mutate( + STOREFRONT_PRIVATE_CREDENTIAL_CREATE_MUTATION, + { + input: { + connectionId, + label, + clientMutationId: mutationId(), + }, + }, + ); + const payload = + data.headlessAppMutation.storefrontPrivateCredentialCreate; + assertSuccess(payload.userErrors); + return payload; + }), + [run, sdk], + ); + + return { + loading, + createStorefront, + renameStorefront, + setSuspended, + disconnectStorefront, + updatePolicy, + createPrivateCredential, + }; +} diff --git a/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefront.ts b/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefront.ts new file mode 100644 index 000000000..11660cb51 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefront.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { AdminAppSdk } from "@shopana/admin-app-sdk"; +import type { ApiHeadlessStorefrontPermissionDefinition } from "@/graphql/types"; +import { HEADLESS_STOREFRONT_QUERY } from "../graphql"; +import type { HeadlessStorefront } from "../graphql/operation-types"; + +export function useHeadlessStorefront( + sdk: AdminAppSdk, + storefrontId: string, +) { + const [storefront, setStorefront] = useState(null); + const [permissionCatalog, setPermissionCatalog] = useState< + ApiHeadlessStorefrontPermissionDefinition[] + >([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + await Promise.resolve(); + setLoading(true); + setError(null); + + try { + const data = await sdk.graphql.query(HEADLESS_STOREFRONT_QUERY, { + id: storefrontId, + }); + setStorefront(data.headlessAppQuery.headlessStorefrontConnection); + setPermissionCatalog( + data.headlessAppQuery.headlessStorefrontPermissionCatalog, + ); + } catch (cause) { + setError( + cause instanceof Error + ? cause + : new Error("Unable to load the storefront."), + ); + } finally { + setLoading(false); + } + }, [sdk, storefrontId]); + + useEffect(() => { + let active = true; + + void sdk.graphql + .query(HEADLESS_STOREFRONT_QUERY, { id: storefrontId }) + .then((data) => { + if (!active) return; + setStorefront(data.headlessAppQuery.headlessStorefrontConnection); + setPermissionCatalog( + data.headlessAppQuery.headlessStorefrontPermissionCatalog, + ); + setLoading(false); + }) + .catch((cause: unknown) => { + if (!active) return; + setError( + cause instanceof Error + ? cause + : new Error("Unable to load the storefront."), + ); + setLoading(false); + }); + + return () => { + active = false; + }; + }, [sdk, storefrontId]); + + return { storefront, permissionCatalog, loading, error, refetch }; +} diff --git a/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefronts.ts b/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefronts.ts new file mode 100644 index 000000000..4c9e4ffa9 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/hooks/use-headless-storefronts.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { AdminAppSdk } from "@shopana/admin-app-sdk"; +import type { ApiHeadlessStorefrontPermissionDefinition } from "@/graphql/types"; +import { HEADLESS_STOREFRONTS_QUERY } from "../graphql"; +import type { HeadlessStorefront } from "../graphql/operation-types"; + +export function useHeadlessStorefronts(sdk: AdminAppSdk) { + const [storefronts, setStorefronts] = useState([]); + const [permissionCatalog, setPermissionCatalog] = useState< + ApiHeadlessStorefrontPermissionDefinition[] + >([]); + const [defaultPermissions, setDefaultPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + await Promise.resolve(); + setLoading(true); + setError(null); + + try { + const data = await sdk.graphql.query(HEADLESS_STOREFRONTS_QUERY, {}); + setStorefronts(data.headlessAppQuery.headlessStorefrontConnections); + setPermissionCatalog( + data.headlessAppQuery.headlessStorefrontPermissionCatalog, + ); + setDefaultPermissions( + data.headlessAppQuery.headlessStorefrontDefaultPermissions, + ); + } catch (cause) { + setError( + cause instanceof Error + ? cause + : new Error("Unable to load storefronts."), + ); + } finally { + setLoading(false); + } + }, [sdk]); + + useEffect(() => { + let active = true; + + void sdk.graphql + .query(HEADLESS_STOREFRONTS_QUERY, {}) + .then((data) => { + if (!active) return; + setStorefronts(data.headlessAppQuery.headlessStorefrontConnections); + setPermissionCatalog( + data.headlessAppQuery.headlessStorefrontPermissionCatalog, + ); + setDefaultPermissions( + data.headlessAppQuery.headlessStorefrontDefaultPermissions, + ); + setLoading(false); + }) + .catch((cause: unknown) => { + if (!active) return; + setError( + cause instanceof Error + ? cause + : new Error("Unable to load storefronts."), + ); + setLoading(false); + }); + + return () => { + active = false; + }; + }, [sdk]); + + return { + storefronts, + permissionCatalog, + defaultPermissions, + loading, + error, + refetch, + }; +} diff --git a/admin/src/domains/apps/shopana-headless/src/modals/create-storefront-modal.tsx b/admin/src/domains/apps/shopana-headless/src/modals/create-storefront-modal.tsx new file mode 100644 index 000000000..7b7afc7df --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/modals/create-storefront-modal.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Alert, Button, Checkbox, Flex, Input, Typography } from "antd"; +import { createStyles } from "antd-style"; +import type { AdminAppModalProps } from "@shopana/admin-app-sdk"; +import { LuCopy } from "react-icons/lu"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useHeadlessStorefrontActions } from "../hooks"; +import { groupStorefrontPermissions } from "../permissions"; +import type { + CreateStorefrontModalPayload, + CreateStorefrontModalResult, +} from "."; + +const useStyles = createStyles(({ token }) => ({ + permissions: { + display: "grid", + gap: `${token.padding}px ${token.paddingLG}px`, + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + marginBottom: token.margin, + [`@media (max-width: ${token.screenSM}px)`]: { + gridTemplateColumns: "1fr", + }, + }, + permissionGroup: { + display: "flex", + flexDirection: "column", + gap: token.paddingXS, + }, + permission: { + display: "flex", + flexDirection: "column", + gap: token.paddingXXS, + }, + permissionDescription: { + paddingLeft: token.paddingLG, + }, + credentialStack: { + display: "flex", + flexDirection: "column", + gap: token.paddingSM, + }, + credentialInput: { + display: "flex", + gap: token.paddingXS, + }, + credentialField: { + display: "flex", + flexDirection: "column", + gap: token.paddingXXS, + }, + privateTokenWarning: { + "&.ant-alert-with-description": { + padding: token.paddingSM, + }, + }, +})); + +function samePermissions(left: string[], right: string[]) { + return ( + left.length === right.length && + left.every((permission) => right.includes(permission)) + ); +} + +export default function CreateStorefrontModal({ + sdk, + payload, +}: AdminAppModalProps) { + const { styles } = useStyles(); + const actions = useHeadlessStorefrontActions(sdk); + const [displayName, setDisplayName] = useState(""); + const [permissions, setPermissions] = useState(payload.defaultPermissions); + const [created, setCreated] = useState<{ + storefrontId: string; + publicAccessToken: string; + privateAccessToken: string; + } | null>(null); + const normalizedName = displayName.trim(); + const isDirty = + Boolean(normalizedName) || + !samePermissions(permissions, payload.defaultPermissions); + const canSubmit = Boolean(normalizedName) && !actions.loading; + const groupedPermissions = useMemo( + () => groupStorefrontPermissions(payload.permissionCatalog), + [payload.permissionCatalog], + ); + + useEffect(() => { + sdk.modals.setCurrentDirty(Boolean(created) || isDirty); + }, [created, isDirty, sdk]); + + const togglePermission = (handle: string, enabled: boolean) => { + setPermissions((current) => + enabled + ? [...new Set([...current, handle])] + : current.filter((permission) => permission !== handle), + ); + }; + + const submit = async () => { + if (!canSubmit) return; + + try { + const result = await actions.createStorefront( + normalizedName, + permissions, + ); + if (!result.connection) { + throw new Error("The storefront was not returned by the API."); + } + let privateAccessToken = + result.initialStorefrontCredentials?.privateAccessToken ?? null; + if (!privateAccessToken) { + const credential = await actions.createPrivateCredential( + result.connection.id, + "Initial private access token", + ); + privateAccessToken = credential.privateAccessToken; + } + if (!privateAccessToken) { + throw new Error("The private access token was not returned by the API."); + } + setCreated({ + storefrontId: result.connection.id, + publicAccessToken: + result.initialStorefrontCredentials?.publicAccessToken ?? + result.connection.publicAccessToken ?? + "", + privateAccessToken, + }); + } catch (error) { + sdk.notifications.error( + "Unable to add storefront", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const finish = () => { + if (!created) return; + sdk.modals.setCurrentDirty(false); + sdk.modals.closeCurrent({ + storefrontId: created.storefrontId, + privateAccessToken: created.privateAccessToken, + }); + }; + + const copyCredential = async (value: string, label: string) => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + sdk.notifications.success(`${label} copied`); + } catch (error) { + sdk.notifications.error( + `Unable to copy ${label.toLowerCase()}`, + error instanceof Error ? error.message : undefined, + ); + } + }; + + return ( + + I saved the token + + ) : ( + + ) + } + title={created ? "Save private access token" : "Add storefront"} + > + + + + Choose the name used to identify this storefront in Admin. + + setDisplayName(target.value)} + /> + + + {created ? ( + + +
    +
    + Public access token +
    + +
    +
    +
    + Private access token +
    + +
    +
    + +
    +
    + ) : null} + + + + + Choose which Storefront API resources this connection can access. + +
    + {groupedPermissions.map((group) => ( +
    + {group.label} + {group.permissions.map((permission) => ( +
    + + togglePermission( + permission.handle, + target.checked, + ) + } + > + {permission.label} + + + {permission.description} + +
    + ))} +
    + ))} +
    + + + {permissions.length} of {payload.permissionCatalog.length}{" "} + permissions enabled + + +
    +
    + ); +} diff --git a/admin/src/domains/apps/shopana-headless/src/modals/disconnect-storefront-modal.tsx b/admin/src/domains/apps/shopana-headless/src/modals/disconnect-storefront-modal.tsx new file mode 100644 index 000000000..7605a5fb0 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/modals/disconnect-storefront-modal.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Button, Input, List, Typography } from "antd"; +import { createStyles } from "antd-style"; +import type { AdminAppModalProps } from "@shopana/admin-app-sdk"; +import { Paper } from "@/ui-kit/paper"; +import { useHeadlessStorefrontActions } from "../hooks"; +import type { + DisconnectStorefrontModalPayload, + DisconnectStorefrontModalResult, +} from "."; + +const useStyles = createStyles(({ token }) => ({ + warningList: { + marginBottom: token.marginMD, + }, + label: { + display: "block", + fontWeight: 500, + marginBottom: token.marginXS, + }, + confirmText: { + backgroundColor: token.colorBgLayout, + borderRadius: token.borderRadiusSM, + fontFamily: "monospace", + padding: "2px 6px", + }, +})); + +export default function DisconnectStorefrontModal({ + sdk, + payload, +}: AdminAppModalProps) { + const { styles } = useStyles(); + const actions = useHeadlessStorefrontActions(sdk); + const [confirmation, setConfirmation] = useState(""); + const confirmationText = `disconnect ${payload.displayName}`; + const isConfirmed = confirmation === confirmationText; + + const submit = async () => { + if (!isConfirmed || actions.loading) return; + + try { + await actions.disconnectStorefront(payload.storefrontId); + sdk.modals.closeCurrent({ + storefrontId: payload.storefrontId, + }); + } catch (error) { + sdk.notifications.error( + "Unable to disconnect storefront", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const consequences = [ + "Revoke the public access token", + "Revoke every private access token", + "Permanently disable Storefront API access", + ]; + + return ( + void submit()} + > + Disconnect storefront + + } + title="Disconnect storefront" + > + + + + + Disconnecting "{payload.displayName}" will: + + + ( + + • {item} + + )} + /> + + + Type {confirmationText} to + confirm + + setConfirmation(target.value)} + onPressEnter={() => void submit()} + /> + + + ); +} diff --git a/admin/src/domains/apps/shopana-headless/src/modals/index.ts b/admin/src/domains/apps/shopana-headless/src/modals/index.ts new file mode 100644 index 000000000..16971e48b --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/modals/index.ts @@ -0,0 +1,33 @@ +import type { ApiHeadlessStorefrontPermissionDefinition } from "@/graphql/types"; + +export const CREATE_STOREFRONT_MODAL_ID = "storefront.create"; +export const RENAME_STOREFRONT_MODAL_ID = "storefront.rename"; +export const DISCONNECT_STOREFRONT_MODAL_ID = "storefront.disconnect"; + +export interface CreateStorefrontModalPayload { + permissionCatalog: ApiHeadlessStorefrontPermissionDefinition[]; + defaultPermissions: string[]; +} + +export interface CreateStorefrontModalResult { + storefrontId: string; + privateAccessToken: string | null; +} + +export interface RenameStorefrontModalPayload { + storefrontId: string; + displayName: string; +} + +export interface RenameStorefrontModalResult { + displayName: string; +} + +export interface DisconnectStorefrontModalPayload { + storefrontId: string; + displayName: string; +} + +export interface DisconnectStorefrontModalResult { + storefrontId: string; +} diff --git a/admin/src/domains/apps/shopana-headless/src/modals/rename-storefront-modal.tsx b/admin/src/domains/apps/shopana-headless/src/modals/rename-storefront-modal.tsx new file mode 100644 index 000000000..743427ccb --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/modals/rename-storefront-modal.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Input, Typography } from "antd"; +import type { AdminAppModalProps } from "@shopana/admin-app-sdk"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useHeadlessStorefrontActions } from "../hooks"; +import type { + RenameStorefrontModalPayload, + RenameStorefrontModalResult, +} from "."; + +export default function RenameStorefrontModal({ + sdk, + payload, +}: AdminAppModalProps) { + const actions = useHeadlessStorefrontActions(sdk); + const [displayName, setDisplayName] = useState(payload.displayName); + const normalizedName = displayName.trim(); + const isDirty = normalizedName !== payload.displayName; + const canSubmit = Boolean(normalizedName) && isDirty && !actions.loading; + + useEffect(() => { + sdk.modals.setCurrentDirty(isDirty); + }, [isDirty, sdk]); + + const submit = async () => { + if (!canSubmit) return; + + try { + await actions.renameStorefront(payload.storefrontId, normalizedName); + sdk.modals.setCurrentDirty(false); + sdk.modals.closeCurrent({ + displayName: normalizedName, + }); + } catch (error) { + sdk.notifications.error( + "Unable to rename storefront", + error instanceof Error ? error.message : undefined, + ); + } + }; + + return ( + void submit()} + > + Rename + + } + title="Rename storefront" + > + + + + Change the name used to identify this storefront in Admin. + + setDisplayName(target.value)} + onPressEnter={() => void submit()} + /> + + + ); +} diff --git a/admin/src/domains/apps/shopana-headless/src/page.tsx b/admin/src/domains/apps/shopana-headless/src/page.tsx new file mode 100644 index 000000000..200d71cec --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/page.tsx @@ -0,0 +1,821 @@ +"use client"; + +import { + Alert, + Button, + Checkbox, + Divider, + Dropdown, + Empty, + Flex, + Input, + List, + Skeleton, + Spin, + Tag, + Typography, +} from "antd"; +import { createStyles } from "antd-style"; +import { useMemo, useState } from "react"; +import { + LuCopy, + LuEllipsis, + LuEye, + LuEyeOff, +} from "react-icons/lu"; +import type { AdminAppPageProps } from "@shopana/admin-app-sdk"; +import { AdminAppLoadingContent } from "@/domains/apps/sdk/ui"; +import { + HeadlessStorefrontConnectionStatus, + StorefrontCredentialKind, + StorefrontCredentialStatus, +} from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + useHeadlessStorefront, + useHeadlessStorefrontActions, + useHeadlessStorefronts, +} from "./hooks"; +import type { HeadlessStorefront } from "./graphql/operation-types"; +import { + CREATE_STOREFRONT_MODAL_ID, + DISCONNECT_STOREFRONT_MODAL_ID, + RENAME_STOREFRONT_MODAL_ID, +} from "./modals"; +import type { + CreateStorefrontModalPayload, + CreateStorefrontModalResult, + DisconnectStorefrontModalPayload, + DisconnectStorefrontModalResult, + RenameStorefrontModalPayload, + RenameStorefrontModalResult, +} from "./modals"; +import { groupStorefrontPermissions } from "./permissions"; + +const useStyles = createStyles(({ token }) => ({ + stack: { + display: "flex", + flexDirection: "column", + gap: token.padding, + }, + storefrontRow: { + alignItems: "center", + cursor: "pointer", + display: "flex", + gap: token.paddingSM, + minWidth: 0, + width: "100%", + }, + storefrontRowDisabled: { + cursor: "not-allowed", + opacity: 0.5, + }, + storefrontCopy: { + display: "flex", + flex: 1, + flexDirection: "column", + minWidth: 0, + }, + connectionStatus: { + alignItems: "center", + display: "flex", + justifyContent: "space-between", + }, + credentialStack: { + display: "flex", + flexDirection: "column", + gap: token.paddingSM, + }, + credentialField: { + display: "flex", + flexDirection: "column", + gap: token.paddingXXS, + }, + credentialInput: { + display: "flex", + gap: token.paddingXS, + }, + privateTokenWarning: { + "&.ant-alert-with-description": { + padding: token.paddingSM, + }, + }, + rotation: { + alignItems: "center", + background: token.colorFillQuaternary, + borderRadius: token.borderRadiusLG, + display: "flex", + justifyContent: "space-between", + padding: token.paddingSM, + }, + permissions: { + display: "grid", + gap: `${token.padding}px ${token.paddingLG}px`, + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + marginBottom: token.margin, + [`@media (max-width: ${token.screenSM}px)`]: { + gridTemplateColumns: "1fr", + }, + }, + permissionGroup: { + display: "flex", + flexDirection: "column", + gap: token.paddingXS, + }, + permission: { + display: "flex", + flexDirection: "column", + gap: token.paddingXXS, + }, + permissionDescription: { + paddingLeft: token.paddingLG, + }, + dangerRow: { + alignItems: "center", + display: "flex", + justifyContent: "space-between", + }, + dangerDivider: { + margin: `${token.marginSM}px 0`, + }, +})); + +const dateFormatter = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", +}); + +const statusPresentation = { + [HeadlessStorefrontConnectionStatus.Active]: { + color: "success", + description: "The storefront can access the Storefront API.", + label: "Active", + }, + [HeadlessStorefrontConnectionStatus.Suspended]: { + color: "warning", + description: "Storefront API access is temporarily disabled.", + label: "Suspended", + }, + [HeadlessStorefrontConnectionStatus.Disconnected]: { + color: "default", + description: "Storefront API access is permanently disabled.", + label: "Disconnected", + }, +} as const; + +function ErrorAlert({ error }: { error: Error | null }) { + return error ? : null; +} + +function StorefrontStatus({ + status, +}: { + status: HeadlessStorefrontConnectionStatus; +}) { + const presentation = statusPresentation[status]; + return {presentation.label}; +} + +function StorefrontsPage({ + sdk, + onPrivateToken, +}: { + sdk: AdminAppPageProps["sdk"]; + onPrivateToken: (storefrontId: string, token: string) => void; +}) { + const { styles } = useStyles(); + const query = useHeadlessStorefronts(sdk); + const actions = useHeadlessStorefrontActions(sdk); + const storefronts = useMemo( + () => + [...query.storefronts].sort( + (left, right) => + Number( + left.status === HeadlessStorefrontConnectionStatus.Disconnected, + ) - + Number( + right.status === HeadlessStorefrontConnectionStatus.Disconnected, + ), + ), + [query.storefronts], + ); + + const openStorefront = (storefrontId: string) => { + sdk.navigation.openAppPath(`storefronts/${storefrontId}`); + }; + + const addStorefront = async () => { + try { + const result = await sdk.modals.openApp< + CreateStorefrontModalPayload, + CreateStorefrontModalResult + >(CREATE_STOREFRONT_MODAL_ID, { + permissionCatalog: query.permissionCatalog, + defaultPermissions: query.defaultPermissions, + }); + if (result.status === "submitted") { + if (result.data.privateAccessToken) { + onPrivateToken( + result.data.storefrontId, + result.data.privateAccessToken, + ); + } + sdk.notifications.success("Storefront added"); + openStorefront(result.data.storefrontId); + } + } catch (error) { + sdk.notifications.error( + "Unable to open storefront settings", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const toggleStatus = async (storefront: HeadlessStorefront) => { + try { + await actions.setSuspended( + storefront.id, + storefront.status === HeadlessStorefrontConnectionStatus.Active, + ); + sdk.notifications.success( + storefront.status === HeadlessStorefrontConnectionStatus.Active + ? "Storefront suspended" + : "Storefront resumed", + ); + await query.refetch(); + } catch (error) { + sdk.notifications.error( + "Unable to update storefront", + error instanceof Error ? error.message : undefined, + ); + } + }; + + return ( + void addStorefront()} + > + Add storefront + + } + > +
    + + + + + {storefronts.length > 0 ? ( + { + const isDisconnected = + storefront.status === + HeadlessStorefrontConnectionStatus.Disconnected; + + return ( + +
    openStorefront(storefront.id) + } + onKeyDown={ + isDisconnected + ? undefined + : (event) => { + if ( + event.key === "Enter" || + event.key === " " + ) { + openStorefront(storefront.id); + } + } + } + > +
    + + {storefront.displayName} + + + Created {dateFormatter.format(new Date(storefront.createdAt))} + +
    + + {!isDisconnected ? ( + { + void toggleStatus(storefront); + }, + }, + ], + }} + trigger={["click"]} + > +
    +
    + ); + }} + /> + ) : query.loading ? null : ( + + )} +
    +
    +
    +
    + ); +} + +function StorefrontDetailPage({ + sdk, + storefrontId, + privateToken, + onPrivateToken, +}: { + sdk: AdminAppPageProps["sdk"]; + storefrontId: string; + privateToken?: string; + onPrivateToken: (storefrontId: string, token: string) => void; +}) { + const { styles } = useStyles(); + const query = useHeadlessStorefront(sdk, storefrontId); + const actions = useHeadlessStorefrontActions(sdk); + const [showPrivateToken, setShowPrivateToken] = useState(false); + const [permissionDraftOverride, setPermissionDraftOverride] = useState< + string[] | null + >(null); + + const storefront = query.storefront; + const accessPolicy = storefront?.storefrontAccessPolicy; + const permissionDraft = + permissionDraftOverride ?? accessPolicy?.permissions ?? []; + + const activePrivateCredential = storefront?.storefrontCredentials.find( + ({ kind, status }) => + kind === StorefrontCredentialKind.Private && + status === StorefrontCredentialStatus.Active, + ); + const privateTokenDisplay = privateToken + ? showPrivateToken + ? privateToken + : privateToken.replace(/.(?=.{4})/g, "•") + : activePrivateCredential + ? `••••••••••••${activePrivateCredential.tokenHint}` + : "No active private token"; + + const copy = async (value: string | null | undefined, label: string) => { + if (!value) return; + await navigator.clipboard.writeText(value); + sdk.notifications.success(`${label} copied`); + }; + + const rename = async () => { + if (!storefront) return; + + try { + const result = await sdk.modals.openApp< + RenameStorefrontModalPayload, + RenameStorefrontModalResult + >(RENAME_STOREFRONT_MODAL_ID, { + storefrontId: storefront.id, + displayName: storefront.displayName, + }); + if (result.status === "submitted") { + sdk.notifications.success("Storefront renamed"); + await query.refetch(); + } + } catch (error) { + sdk.notifications.error( + "Unable to open rename storefront", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const rotatePrivateToken = async () => { + if (!storefront) return; + + try { + const result = await actions.createPrivateCredential( + storefront.id, + `Rotated ${new Date().toISOString()}`, + ); + if (!result.privateAccessToken) { + throw new Error("The private token was not returned by the API."); + } + onPrivateToken(storefront.id, result.privateAccessToken); + setShowPrivateToken(true); + sdk.notifications.success("Private token generated"); + await query.refetch(); + } catch (error) { + sdk.notifications.error( + "Unable to generate token", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const toggleConnectionStatus = async () => { + if (!storefront) return; + const suspend = + storefront.status === HeadlessStorefrontConnectionStatus.Active; + + try { + await actions.setSuspended(storefront.id, suspend); + sdk.notifications.success( + suspend ? "Storefront suspended" : "Storefront resumed", + ); + await query.refetch(); + } catch (error) { + sdk.notifications.error( + "Unable to update storefront", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const togglePermission = (handle: string, enabled: boolean) => { + setPermissionDraftOverride((current) => { + const permissions = current ?? accessPolicy?.permissions ?? []; + return enabled + ? [...new Set([...permissions, handle])] + : permissions.filter((permission) => permission !== handle); + }); + }; + + const savedPermissions = accessPolicy?.permissions ?? []; + const hasPermissionChanges = + permissionDraft.length !== savedPermissions.length || + permissionDraft.some( + (permission) => !savedPermissions.includes(permission), + ); + + const savePermissions = async () => { + if (!storefront || !accessPolicy || !hasPermissionChanges) return; + try { + await actions.updatePolicy( + storefront.id, + permissionDraft, + accessPolicy.revision, + ); + sdk.notifications.success("Storefront permissions saved"); + await query.refetch(); + setPermissionDraftOverride(null); + } catch (error) { + sdk.notifications.error( + "Unable to update permissions", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const disconnect = async () => { + if (!storefront) return; + + try { + const result = await sdk.modals.openApp< + DisconnectStorefrontModalPayload, + DisconnectStorefrontModalResult + >(DISCONNECT_STOREFRONT_MODAL_ID, { + storefrontId: storefront.id, + displayName: storefront.displayName, + }); + if (result.status === "submitted") { + sdk.notifications.success("Storefront disconnected"); + sdk.navigation.openAppPath("storefronts"); + } + } catch (error) { + sdk.notifications.error( + "Unable to open disconnect storefront", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const groupedPermissions = useMemo( + () => groupStorefrontPermissions(query.permissionCatalog), + [query.permissionCatalog], + ); + + return ( + sdk.navigation.openAppPath("storefronts")} + title={ + storefront?.displayName ?? ( + + ) + } + > + {query.loading && !storefront ? ( + + ) : ( + +
    + + {storefront ? ( + <> + + { + void toggleConnectionStatus(); + }, + }, + ], + }} + trigger={["click"]} + > +
    + +
    + Private access token + + Use only in trusted server-side storefront contexts. + +
    + +
    +
    + {privateToken ? ( + + ) : ( +
    + + + Rotate private access token + + + The existing token remains valid until you revoke it. + + + +
    + )} + + + + + void savePermissions()} + > + Save + + ) : null + } + title="Permissions" + /> +
    + {groupedPermissions.map((group) => ( +
    + {group.label} + {group.permissions.map((permission) => ( +
    + + togglePermission( + permission.handle, + target.checked, + ) + } + > + {permission.label} + + + {permission.description} + +
    + ))} +
    + ))} +
    + + + {permissionDraft.length} of {query.permissionCatalog.length}{" "} + permissions enabled + + {accessPolicy ? ( + + Policy revision {accessPolicy.revision} + + ) : null} + +
    + + + +
    + + Rename storefront + + Change the name used to identify this storefront. + + + +
    + +
    + + + Disconnect storefront + + + Permanently invalidates every credential issued for this + storefront. + + + +
    +
    + + ) : query.loading ? null : ( + + )} + +
    + )} +
    + ); +} + +export default function HeadlessAdminPage({ + sdk, + route, +}: AdminAppPageProps) { + const [privateTokens, setPrivateTokens] = useState>({}); + const routeSegments = route.appPath.split("/").filter(Boolean); + const storefrontId = + routeSegments[0] === "storefronts" && routeSegments.length > 1 + ? routeSegments.slice(1).join("/") + : null; + const rememberPrivateToken = (id: string, token: string) => { + setPrivateTokens((current) => ({ ...current, [id]: token })); + }; + + if (storefrontId) { + return ( + + ); + } + + return ( + + ); +} diff --git a/admin/src/domains/apps/shopana-headless/src/permissions.ts b/admin/src/domains/apps/shopana-headless/src/permissions.ts new file mode 100644 index 000000000..709459358 --- /dev/null +++ b/admin/src/domains/apps/shopana-headless/src/permissions.ts @@ -0,0 +1,21 @@ +import type { ApiHeadlessStorefrontPermissionDefinition } from "@/graphql/types"; + +const permissionGroups = [ + { label: "Catalog & inventory", resources: ["catalog", "inventory"] }, + { label: "Checkout", resources: ["checkout"] }, + { label: "Customers", resources: ["customer"] }, + { label: "Orders", resources: ["order"] }, +] as const; + +export function groupStorefrontPermissions( + catalog: ApiHeadlessStorefrontPermissionDefinition[], +) { + return permissionGroups.map((group) => ({ + ...group, + permissions: catalog.filter((permission) => + group.resources.some( + (resource) => resource === permission.resource, + ), + ), + })); +} diff --git a/admin/src/domains/apps/shopana-online-store/module-federation.config.ts b/admin/src/domains/apps/shopana-online-store/module-federation.config.ts new file mode 100644 index 000000000..476ce547f --- /dev/null +++ b/admin/src/domains/apps/shopana-online-store/module-federation.config.ts @@ -0,0 +1,24 @@ +const singleton = { + singleton: true, + requiredVersion: false, +} as const; + +const config = { + name: "shopana_online_store_admin", + filename: "remoteEntry.js", + exposes: { + "./Page": "./src/page.tsx", + }, + shared: { + react: singleton, + "react-dom": singleton, + antd: singleton, + "antd-style": singleton, + "@ant-design/cssinjs": singleton, + "@apollo/client": singleton, + "ag-grid-react": singleton, + "ag-grid-community": singleton, + }, +} as const; + +export default config; diff --git a/admin/src/domains/apps/shopana-online-store/register.ts b/admin/src/domains/apps/shopana-online-store/register.ts new file mode 100644 index 000000000..15bbc31cc --- /dev/null +++ b/admin/src/domains/apps/shopana-online-store/register.ts @@ -0,0 +1,10 @@ +import { registerLocalAdminApp } from "../runtime/local-app-registry"; + +registerLocalAdminApp( + { + appCode: "shopana-online-store", + remoteName: "shopana_online_store_admin", + pageModule: "./Page", + }, + () => import("./src/page"), +); diff --git a/packages/checkout-sdk/src/types.js b/admin/src/domains/apps/shopana-online-store/src/extensions/index.ts similarity index 100% rename from packages/checkout-sdk/src/types.js rename to admin/src/domains/apps/shopana-online-store/src/extensions/index.ts diff --git a/admin/src/domains/apps/shopana-online-store/src/modals/index.ts b/admin/src/domains/apps/shopana-online-store/src/modals/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/admin/src/domains/apps/shopana-online-store/src/modals/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/admin/src/domains/apps/shopana-online-store/src/page.tsx b/admin/src/domains/apps/shopana-online-store/src/page.tsx new file mode 100644 index 000000000..df3d883c1 --- /dev/null +++ b/admin/src/domains/apps/shopana-online-store/src/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { Alert } from "antd"; +import type { AdminAppPageProps } from "@shopana/admin-app-sdk"; + +export default function OnlineStoreAdminPage({ sdk }: AdminAppPageProps) { + return ( + + + + ); +} diff --git a/admin/src/domains/apps/shopana-smtp/module-federation.config.ts b/admin/src/domains/apps/shopana-smtp/module-federation.config.ts new file mode 100644 index 000000000..007dac8d0 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/module-federation.config.ts @@ -0,0 +1,27 @@ +const singleton = { + singleton: true, + requiredVersion: false, +} as const; + +const config = { + name: "shopana_smtp_admin", + filename: "remoteEntry.js", + exposes: { + "./Page": "./src/page.tsx", + "./CreateSmtpConnectionModal": + "./src/modals/create-smtp-connection-modal.tsx", + "./SmtpDisconnectModal": "./src/modals/smtp-disconnect-modal.tsx", + }, + shared: { + react: singleton, + "react-dom": singleton, + antd: singleton, + "antd-style": singleton, + "@ant-design/cssinjs": singleton, + "@apollo/client": singleton, + "ag-grid-react": singleton, + "ag-grid-community": singleton, + }, +} as const; + +export default config; diff --git a/admin/src/domains/apps/shopana-smtp/register.ts b/admin/src/domains/apps/shopana-smtp/register.ts new file mode 100644 index 000000000..d484efb2b --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/register.ts @@ -0,0 +1,29 @@ +import { registerLocalAdminApp } from "../runtime/local-app-registry"; +import { + CREATE_SMTP_CONNECTION_MODAL_ID, + SMTP_DISCONNECT_MODAL_ID, +} from "./src/modals"; + +registerLocalAdminApp( + { + appCode: "shopana-smtp", + remoteName: "shopana_smtp_admin", + pageModule: "./Page", + defaultPath: "connections", + modals: [ + { + id: CREATE_SMTP_CONNECTION_MODAL_ID, + module: "./CreateSmtpConnectionModal", + confirmOnDirtyClose: true, + closeConfirmMessage: "Discard SMTP connection settings?", + load: () => import("./src/modals/create-smtp-connection-modal"), + }, + { + id: SMTP_DISCONNECT_MODAL_ID, + module: "./SmtpDisconnectModal", + load: () => import("./src/modals/smtp-disconnect-modal"), + }, + ], + }, + () => import("./src/page"), +); diff --git a/admin/src/domains/apps/shopana-smtp/src/components/smtp-settings-form.tsx b/admin/src/domains/apps/shopana-smtp/src/components/smtp-settings-form.tsx new file mode 100644 index 000000000..c5e16e478 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/components/smtp-settings-form.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useState } from "react"; +import { Button, Input, InputNumber, Select, Typography } from "antd"; +import { createStyles } from "antd-style"; +import { + SmtpConnectionProvider, + SmtpConnectionSecurity, +} from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { SmtpProviderPreset } from "../graphql/operation-types"; + +const useStyles = createStyles(({ token }) => ({ + fields: { + display: "grid", + gap: `${token.paddingSM}px ${token.padding}px`, + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + [`@media (max-width: ${token.screenSM}px)`]: { + gridTemplateColumns: "1fr", + }, + }, + field: { + display: "flex", + flexDirection: "column", + gap: token.paddingXXS, + minWidth: 0, + }, + fieldFull: { + gridColumn: "1 / -1", + }, + label: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + help: { + color: token.colorTextTertiary, + fontSize: token.fontSizeSM, + }, +})); + +export interface SmtpSettings { + displayName: string; + provider: SmtpConnectionProvider; + host: string; + port: number; + security: SmtpConnectionSecurity; + username?: string; + password?: string; +} + +interface SmtpSettingsFormProps { + disabled?: boolean; + hasStoredPassword: boolean; + initialValue: SmtpSettings; + loading: boolean; + presets: SmtpProviderPreset[]; + submitLabel: string; + onSubmit: (settings: SmtpSettings) => Promise; +} + +export function SmtpSettingsForm({ + disabled = false, + hasStoredPassword, + initialValue, + loading, + presets, + submitLabel, + onSubmit, +}: SmtpSettingsFormProps) { + const { styles, cx } = useStyles(); + const [settings, setSettings] = useState(initialValue); + const [initialSettings, setInitialSettings] = useState(() => + JSON.stringify(initialValue), + ); + const isDirty = JSON.stringify(settings) !== initialSettings; + const canSubmit = Boolean( + !disabled && + isDirty && + settings.displayName.trim() && + settings.host.trim() && + settings.port >= 1 && + settings.port <= 65535 && + (settings.provider === SmtpConnectionProvider.Custom || + settings.username?.trim()) && + (!settings.username?.trim() || + hasStoredPassword || + settings.password?.trim()), + ); + + const update = ( + key: TKey, + value: SmtpSettings[TKey], + ) => { + setSettings((current) => ({ ...current, [key]: value })); + }; + + const applyProvider = (provider: SmtpConnectionProvider) => { + const preset = presets.find( + (candidate) => candidate.provider === provider, + ); + setSettings((current) => ({ + ...current, + provider, + host: preset?.host ?? "", + port: preset?.port ?? 587, + security: preset?.security ?? SmtpConnectionSecurity.Starttls, + username: preset?.username ?? "", + })); + }; + + const submit = async () => { + if (!canSubmit || loading) return; + const normalizedSettings = { + ...settings, + displayName: settings.displayName.trim(), + host: settings.host.trim(), + username: settings.username?.trim() || undefined, + password: settings.password?.trim() || undefined, + }; + if (await onSubmit(normalizedSettings)) { + setSettings(normalizedSettings); + setInitialSettings(JSON.stringify(normalizedSettings)); + } + }; + + return ( + <> + + +
    + + + + + +
    +
    + + ); +} diff --git a/admin/src/domains/apps/shopana-smtp/src/extensions/index.ts b/admin/src/domains/apps/shopana-smtp/src/extensions/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/extensions/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/admin/src/domains/apps/shopana-smtp/src/graphql/fragments.ts b/admin/src/domains/apps/shopana-smtp/src/graphql/fragments.ts new file mode 100644 index 000000000..e309c1c61 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/graphql/fragments.ts @@ -0,0 +1,17 @@ +import { gql } from "@apollo/client"; + +export const SMTP_CONNECTION_FIELDS = gql` + fragment SmtpConnectionFields on SmtpConnection { + id + displayName + provider + status + host + port + security + username + hasPassword + createdAt + updatedAt + } +`; diff --git a/admin/src/domains/apps/shopana-smtp/src/graphql/index.ts b/admin/src/domains/apps/shopana-smtp/src/graphql/index.ts new file mode 100644 index 000000000..30f4c6413 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/graphql/index.ts @@ -0,0 +1,4 @@ +export * from "./fragments"; +export * from "./queries"; +export * from "./mutations"; +export * from "./operation-types"; diff --git a/admin/src/domains/apps/shopana-smtp/src/graphql/mutations.ts b/admin/src/domains/apps/shopana-smtp/src/graphql/mutations.ts new file mode 100644 index 000000000..bc065eb9b --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/graphql/mutations.ts @@ -0,0 +1,95 @@ +import { gql } from "@apollo/client"; +import type { TypedDocumentNodeLike } from "@shopana/admin-app-sdk"; +import { SMTP_CONNECTION_FIELDS } from "./fragments"; +import type { + SmtpConnectionActionMutationData, + SmtpConnectionActionMutationVariables, + SmtpConnectionCreateMutationData, + SmtpConnectionCreateMutationVariables, + SmtpConnectionUpdateMutationData, + SmtpConnectionUpdateMutationVariables, +} from "./operation-types"; + +export const SMTP_CONNECTION_CREATE_MUTATION = gql` + mutation SmtpConnectionCreate($input: SmtpConnectionCreateInput!) { + smtpAppMutation { + smtpConnectionCreate(input: $input) { + connection { + ...SmtpConnectionFields + } + userErrors { + code + field + message + } + } + } + } + ${SMTP_CONNECTION_FIELDS} +` as TypedDocumentNodeLike< + SmtpConnectionCreateMutationData, + SmtpConnectionCreateMutationVariables +>; + +export const SMTP_CONNECTION_UPDATE_MUTATION = gql` + mutation SmtpConnectionUpdate($input: SmtpConnectionUpdateInput!) { + smtpAppMutation { + smtpConnectionUpdate(input: $input) { + connection { + ...SmtpConnectionFields + } + userErrors { + code + field + message + } + } + } + } + ${SMTP_CONNECTION_FIELDS} +` as TypedDocumentNodeLike< + SmtpConnectionUpdateMutationData, + SmtpConnectionUpdateMutationVariables +>; + +export const SMTP_CONNECTION_ACTIVATE_MUTATION = gql` + mutation SmtpConnectionActivate($input: SmtpConnectionActionInput!) { + smtpAppMutation { + smtpConnectionActivate(input: $input) { + connection { + ...SmtpConnectionFields + } + userErrors { + code + field + message + } + } + } + } + ${SMTP_CONNECTION_FIELDS} +` as TypedDocumentNodeLike< + SmtpConnectionActionMutationData, + SmtpConnectionActionMutationVariables +>; + +export const SMTP_CONNECTION_DISCONNECT_MUTATION = gql` + mutation SmtpConnectionDisconnect($input: SmtpConnectionActionInput!) { + smtpAppMutation { + smtpConnectionDisconnect(input: $input) { + connection { + ...SmtpConnectionFields + } + userErrors { + code + field + message + } + } + } + } + ${SMTP_CONNECTION_FIELDS} +` as TypedDocumentNodeLike< + SmtpConnectionActionMutationData, + SmtpConnectionActionMutationVariables +>; diff --git a/admin/src/domains/apps/shopana-smtp/src/graphql/operation-types.ts b/admin/src/domains/apps/shopana-smtp/src/graphql/operation-types.ts new file mode 100644 index 000000000..8539427f2 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/graphql/operation-types.ts @@ -0,0 +1,84 @@ +import type { + ApiGenericUserError, + ApiSmtpConnection, + ApiSmtpConnectionActionInput, + ApiSmtpConnectionCreateInput, + ApiSmtpConnectionUpdateInput, + ApiSmtpProviderPreset, +} from "@/graphql/types"; + +export type SmtpConnection = Pick< + ApiSmtpConnection, + | "id" + | "displayName" + | "provider" + | "status" + | "host" + | "port" + | "security" + | "username" + | "hasPassword" + | "createdAt" + | "updatedAt" +>; + +export type SmtpProviderPreset = Pick< + ApiSmtpProviderPreset, + "provider" | "label" | "host" | "port" | "security" | "username" +>; + +export interface SmtpConnectionsQueryData { + smtpAppQuery: { + smtpConnections: SmtpConnection[]; + smtpProviderPresets: SmtpProviderPreset[]; + }; +} + +export type SmtpConnectionsQueryVariables = Record; + +export interface SmtpConnectionQueryData { + smtpAppQuery: { + smtpConnection: SmtpConnection | null; + smtpProviderPresets: SmtpProviderPreset[]; + }; +} + +export interface SmtpConnectionQueryVariables { + id: string; +} + +interface SmtpConnectionPayload { + connection: SmtpConnection | null; + userErrors: ApiGenericUserError[]; +} + +export interface SmtpConnectionCreateMutationData { + smtpAppMutation: { + smtpConnectionCreate: SmtpConnectionPayload; + }; +} + +export interface SmtpConnectionCreateMutationVariables { + input: ApiSmtpConnectionCreateInput; +} + +export interface SmtpConnectionUpdateMutationData { + smtpAppMutation: { + smtpConnectionUpdate: SmtpConnectionPayload; + }; +} + +export interface SmtpConnectionUpdateMutationVariables { + input: ApiSmtpConnectionUpdateInput; +} + +export interface SmtpConnectionActionMutationData { + smtpAppMutation: { + smtpConnectionActivate?: SmtpConnectionPayload; + smtpConnectionDisconnect?: SmtpConnectionPayload; + }; +} + +export interface SmtpConnectionActionMutationVariables { + input: ApiSmtpConnectionActionInput; +} diff --git a/admin/src/domains/apps/shopana-smtp/src/graphql/queries.ts b/admin/src/domains/apps/shopana-smtp/src/graphql/queries.ts new file mode 100644 index 000000000..3778d8854 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/graphql/queries.ts @@ -0,0 +1,53 @@ +import { gql } from "@apollo/client"; +import type { TypedDocumentNodeLike } from "@shopana/admin-app-sdk"; +import { SMTP_CONNECTION_FIELDS } from "./fragments"; +import type { + SmtpConnectionQueryData, + SmtpConnectionQueryVariables, + SmtpConnectionsQueryData, + SmtpConnectionsQueryVariables, +} from "./operation-types"; + +export const SMTP_CONNECTIONS_QUERY = gql` + query SmtpConnections { + smtpAppQuery { + smtpProviderPresets { + provider + label + host + port + security + username + } + smtpConnections { + ...SmtpConnectionFields + } + } + } + ${SMTP_CONNECTION_FIELDS} +` as TypedDocumentNodeLike< + SmtpConnectionsQueryData, + SmtpConnectionsQueryVariables +>; + +export const SMTP_CONNECTION_QUERY = gql` + query SmtpConnection($id: ID!) { + smtpAppQuery { + smtpProviderPresets { + provider + label + host + port + security + username + } + smtpConnection(id: $id) { + ...SmtpConnectionFields + } + } + } + ${SMTP_CONNECTION_FIELDS} +` as TypedDocumentNodeLike< + SmtpConnectionQueryData, + SmtpConnectionQueryVariables +>; diff --git a/admin/src/domains/apps/shopana-smtp/src/hooks/index.ts b/admin/src/domains/apps/shopana-smtp/src/hooks/index.ts new file mode 100644 index 000000000..8fb540525 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./use-smtp-connections"; +export * from "./use-smtp-connection"; +export * from "./use-smtp-connection-actions"; diff --git a/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connection-actions.ts b/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connection-actions.ts new file mode 100644 index 000000000..037d217be --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connection-actions.ts @@ -0,0 +1,100 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { AdminAppSdk } from "@shopana/admin-app-sdk"; +import type { + ApiGenericUserError, + ApiSmtpConnectionCreateInput, + ApiSmtpConnectionUpdateInput, +} from "@/graphql/types"; +import { + SMTP_CONNECTION_ACTIVATE_MUTATION, + SMTP_CONNECTION_CREATE_MUTATION, + SMTP_CONNECTION_DISCONNECT_MUTATION, + SMTP_CONNECTION_UPDATE_MUTATION, +} from "../graphql"; + +function assertSuccess(userErrors: ApiGenericUserError[]) { + if (userErrors.length > 0) { + throw new Error(userErrors.map(({ message }) => message).join("\n")); + } +} + +export function useSmtpConnectionActions(sdk: AdminAppSdk) { + const [loading, setLoading] = useState(false); + + const run = useCallback(async (operation: () => Promise) => { + setLoading(true); + try { + return await operation(); + } finally { + setLoading(false); + } + }, []); + + const createConnection = useCallback( + (input: ApiSmtpConnectionCreateInput) => + run(async () => { + const data = await sdk.graphql.mutate( + SMTP_CONNECTION_CREATE_MUTATION, + { input }, + ); + const payload = data.smtpAppMutation.smtpConnectionCreate; + assertSuccess(payload.userErrors); + return payload.connection; + }), + [run, sdk], + ); + + const updateConnection = useCallback( + (input: ApiSmtpConnectionUpdateInput) => + run(async () => { + const data = await sdk.graphql.mutate( + SMTP_CONNECTION_UPDATE_MUTATION, + { input }, + ); + const payload = data.smtpAppMutation.smtpConnectionUpdate; + assertSuccess(payload.userErrors); + return payload.connection; + }), + [run, sdk], + ); + + const activateConnection = useCallback( + (connectionId: string) => + run(async () => { + const data = await sdk.graphql.mutate( + SMTP_CONNECTION_ACTIVATE_MUTATION, + { input: { connectionId } }, + ); + const payload = data.smtpAppMutation.smtpConnectionActivate; + if (!payload) throw new Error("SMTP connection was not activated."); + assertSuccess(payload.userErrors); + return payload.connection; + }), + [run, sdk], + ); + + const disconnectConnection = useCallback( + (connectionId: string) => + run(async () => { + const data = await sdk.graphql.mutate( + SMTP_CONNECTION_DISCONNECT_MUTATION, + { input: { connectionId } }, + ); + const payload = data.smtpAppMutation.smtpConnectionDisconnect; + if (!payload) throw new Error("SMTP connection was not disconnected."); + assertSuccess(payload.userErrors); + return payload.connection; + }), + [run, sdk], + ); + + return { + loading, + createConnection, + updateConnection, + activateConnection, + disconnectConnection, + }; +} diff --git a/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connection.ts b/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connection.ts new file mode 100644 index 000000000..5e8fbf02c --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connection.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { AdminAppSdk } from "@shopana/admin-app-sdk"; +import { SMTP_CONNECTION_QUERY } from "../graphql"; +import type { + SmtpConnection, + SmtpProviderPreset, +} from "../graphql/operation-types"; + +export function useSmtpConnection( + sdk: AdminAppSdk, + connectionId: string, +) { + const [connection, setConnection] = useState(null); + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await sdk.graphql.query(SMTP_CONNECTION_QUERY, { + id: connectionId, + }); + setConnection(data.smtpAppQuery.smtpConnection); + setPresets(data.smtpAppQuery.smtpProviderPresets); + } catch (cause) { + setError( + cause instanceof Error + ? cause + : new Error("Unable to load SMTP connection."), + ); + } finally { + setLoading(false); + } + }, [connectionId, sdk]); + + useEffect(() => { + void refetch(); + }, [refetch]); + + return { connection, presets, loading, error, refetch }; +} diff --git a/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connections.ts b/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connections.ts new file mode 100644 index 000000000..57aa3f115 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/hooks/use-smtp-connections.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { AdminAppSdk } from "@shopana/admin-app-sdk"; +import { SMTP_CONNECTIONS_QUERY } from "../graphql"; +import type { + SmtpConnection, + SmtpProviderPreset, +} from "../graphql/operation-types"; + +export function useSmtpConnections(sdk: AdminAppSdk) { + const [connections, setConnections] = useState([]); + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await sdk.graphql.query(SMTP_CONNECTIONS_QUERY, {}); + setConnections(data.smtpAppQuery.smtpConnections); + setPresets(data.smtpAppQuery.smtpProviderPresets); + } catch (cause) { + setError( + cause instanceof Error + ? cause + : new Error("Unable to load SMTP connections."), + ); + } finally { + setLoading(false); + } + }, [sdk]); + + useEffect(() => { + void refetch(); + }, [refetch]); + + return { connections, presets, loading, error, refetch }; +} diff --git a/admin/src/domains/apps/shopana-smtp/src/modals/create-smtp-connection-modal.tsx b/admin/src/domains/apps/shopana-smtp/src/modals/create-smtp-connection-modal.tsx new file mode 100644 index 000000000..8d5e3937d --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/modals/create-smtp-connection-modal.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Input, InputNumber, Select, Typography } from "antd"; +import { createStyles } from "antd-style"; +import type { AdminAppModalProps } from "@shopana/admin-app-sdk"; +import { + SmtpConnectionProvider, + SmtpConnectionSecurity, +} from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { SmtpSettings } from "../components/smtp-settings-form"; +import { useSmtpConnectionActions } from "../hooks"; +import type { + CreateSmtpConnectionModalPayload, + CreateSmtpConnectionModalResult, +} from "."; + +const useStyles = createStyles(({ token }) => ({ + fields: { + display: "grid", + gap: `${token.paddingSM}px ${token.padding}px`, + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + [`@media (max-width: ${token.screenSM}px)`]: { + gridTemplateColumns: "1fr", + }, + }, + field: { + display: "flex", + flexDirection: "column", + gap: token.paddingXXS, + minWidth: 0, + }, + fieldFull: { + gridColumn: "1 / -1", + }, + label: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + help: { + color: token.colorTextTertiary, + fontSize: token.fontSizeSM, + }, +})); + +function createInitialSettings( + payload: CreateSmtpConnectionModalPayload, +): SmtpSettings { + const preset = + payload.presets.find( + ({ provider }) => provider === SmtpConnectionProvider.Sendgrid, + ) ?? payload.presets[0]; + + return { + displayName: "", + provider: preset?.provider ?? SmtpConnectionProvider.Custom, + host: preset?.host ?? "", + port: preset?.port ?? 587, + security: preset?.security ?? SmtpConnectionSecurity.Starttls, + username: preset?.username ?? "", + password: "", + }; +} + +export default function CreateSmtpConnectionModal({ + sdk, + payload, +}: AdminAppModalProps) { + const { styles, cx } = useStyles(); + const actions = useSmtpConnectionActions(sdk); + const [settings, setSettings] = useState(() => + createInitialSettings(payload), + ); + const isDirty = JSON.stringify(settings) !== JSON.stringify( + createInitialSettings(payload), + ); + const canSubmit = Boolean( + !actions.loading && + settings.displayName.trim() && + settings.host.trim() && + settings.port >= 1 && + settings.port <= 65535 && + (settings.provider === SmtpConnectionProvider.Custom || + settings.username?.trim()) && + (!settings.username?.trim() || settings.password?.trim()), + ); + + useEffect(() => { + sdk.modals.setCurrentDirty(isDirty); + }, [isDirty, sdk]); + + const update = ( + key: TKey, + value: SmtpSettings[TKey], + ) => { + setSettings((current) => ({ ...current, [key]: value })); + }; + + const applyProvider = (provider: SmtpConnectionProvider) => { + const preset = payload.presets.find( + (candidate) => candidate.provider === provider, + ); + setSettings((current) => ({ + ...current, + provider, + host: preset?.host ?? "", + port: preset?.port ?? 587, + security: preset?.security ?? SmtpConnectionSecurity.Starttls, + username: preset?.username ?? "", + })); + }; + + const submit = async () => { + if (!canSubmit) return; + + try { + const connection = await actions.createConnection({ + ...settings, + displayName: settings.displayName.trim(), + host: settings.host.trim(), + username: settings.username?.trim() || undefined, + password: settings.password?.trim() || undefined, + }); + if (!connection) { + throw new Error("The SMTP connection was not returned by the API."); + } + sdk.modals.setCurrentDirty(false); + sdk.modals.closeCurrent({ + connectionId: connection.id, + }); + } catch (error) { + sdk.notifications.error( + "Unable to add SMTP connection", + error instanceof Error ? error.message : undefined, + ); + } + }; + + return ( + void submit()} + > + Add connection + + } + title="Add SMTP connection" + > + + +
    + + + + + +
    +
    +
    + ); +} diff --git a/admin/src/domains/apps/shopana-smtp/src/modals/index.ts b/admin/src/domains/apps/shopana-smtp/src/modals/index.ts new file mode 100644 index 000000000..a0ccb91ed --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/modals/index.ts @@ -0,0 +1,21 @@ +import type { SmtpProviderPreset } from "../graphql/operation-types"; + +export const CREATE_SMTP_CONNECTION_MODAL_ID = "smtp.connection.create"; +export const SMTP_DISCONNECT_MODAL_ID = "smtp.disconnect"; + +export interface CreateSmtpConnectionModalPayload { + presets: SmtpProviderPreset[]; +} + +export interface CreateSmtpConnectionModalResult { + connectionId: string; +} + +export interface SmtpDisconnectModalPayload { + connectionId: string; + displayName: string; +} + +export interface SmtpDisconnectModalResult { + connectionId: string; +} diff --git a/admin/src/domains/apps/shopana-smtp/src/modals/smtp-disconnect-modal.tsx b/admin/src/domains/apps/shopana-smtp/src/modals/smtp-disconnect-modal.tsx new file mode 100644 index 000000000..cbb3248d6 --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/modals/smtp-disconnect-modal.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Button, Input, List, Typography } from "antd"; +import { createStyles } from "antd-style"; +import type { AdminAppModalProps } from "@shopana/admin-app-sdk"; +import { Paper } from "@/ui-kit/paper"; +import { useSmtpConnectionActions } from "../hooks"; +import type { + SmtpDisconnectModalPayload, + SmtpDisconnectModalResult, +} from "."; + +const useStyles = createStyles(({ token }) => ({ + warningList: { + marginBottom: token.marginMD, + }, + label: { + display: "block", + fontWeight: 500, + marginBottom: token.marginXS, + }, + confirmText: { + backgroundColor: token.colorBgLayout, + borderRadius: token.borderRadiusSM, + fontFamily: "monospace", + padding: "2px 6px", + }, +})); + +export default function SmtpDisconnectModal({ + sdk, + payload, +}: AdminAppModalProps) { + const { styles } = useStyles(); + const actions = useSmtpConnectionActions(sdk); + const [confirmation, setConfirmation] = useState(""); + const confirmationText = `disconnect ${payload.displayName}`; + const isConfirmed = confirmation === confirmationText; + + const submit = async () => { + if (!isConfirmed || actions.loading) return; + + try { + await actions.disconnectConnection(payload.connectionId); + sdk.modals.closeCurrent({ + connectionId: payload.connectionId, + }); + } catch (error) { + sdk.notifications.error( + "Unable to disconnect SMTP connection", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const consequences = [ + "Erase the stored password or API key", + "Permanently disable this SMTP connection", + "Stop email delivery if this is the active connection", + ]; + + return ( + void submit()} + > + Disconnect connection + + } + title="Disconnect SMTP connection" + > + + + + + Disconnecting "{payload.displayName}" will: + + + ( + + • {item} + + )} + /> + + + Type {confirmationText} to + confirm + + setConfirmation(target.value)} + onPressEnter={() => void submit()} + /> + + + ); +} diff --git a/admin/src/domains/apps/shopana-smtp/src/page.tsx b/admin/src/domains/apps/shopana-smtp/src/page.tsx new file mode 100644 index 000000000..e4b9a2fcd --- /dev/null +++ b/admin/src/domains/apps/shopana-smtp/src/page.tsx @@ -0,0 +1,485 @@ +"use client"; + +import { useMemo } from "react"; +import { + Alert, + Button, + Divider, + Dropdown, + Empty, + Flex, + List, + Skeleton, + Spin, + Tag, + Typography, +} from "antd"; +import { createStyles } from "antd-style"; +import { LuEllipsis } from "react-icons/lu"; +import type { AdminAppPageProps } from "@shopana/admin-app-sdk"; +import { AdminAppLoadingContent } from "@/domains/apps/sdk/ui"; +import { SmtpConnectionStatus } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + SmtpSettingsForm, + type SmtpSettings, +} from "./components/smtp-settings-form"; +import { + useSmtpConnection, + useSmtpConnectionActions, + useSmtpConnections, +} from "./hooks"; +import type { SmtpConnection } from "./graphql/operation-types"; +import { + CREATE_SMTP_CONNECTION_MODAL_ID, + SMTP_DISCONNECT_MODAL_ID, + type CreateSmtpConnectionModalPayload, + type CreateSmtpConnectionModalResult, + type SmtpDisconnectModalPayload, + type SmtpDisconnectModalResult, +} from "./modals"; + +const useStyles = createStyles(({ token }) => ({ + stack: { + display: "flex", + flexDirection: "column", + gap: token.padding, + }, + connectionRow: { + alignItems: "center", + cursor: "pointer", + display: "flex", + gap: token.paddingSM, + minWidth: 0, + width: "100%", + }, + connectionRowDisabled: { + cursor: "not-allowed", + opacity: 0.5, + }, + connectionCopy: { + display: "flex", + flex: 1, + flexDirection: "column", + minWidth: 0, + }, + connectionStatus: { + alignItems: "center", + display: "flex", + justifyContent: "space-between", + }, + dangerRow: { + alignItems: "center", + display: "flex", + justifyContent: "space-between", + }, + dangerDivider: { + margin: `${token.marginSM}px 0`, + }, +})); + +const dateFormatter = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", +}); + +const statusPresentation = { + [SmtpConnectionStatus.Active]: { + color: "success", + description: "This connection delivers the store's outgoing email.", + label: "Active", + }, + [SmtpConnectionStatus.Inactive]: { + color: "warning", + description: + "This connection is configured but is not currently used for delivery.", + label: "Inactive", + }, + [SmtpConnectionStatus.Disconnected]: { + color: "default", + description: + "This connection is permanently disabled and its credential was erased.", + label: "Disconnected", + }, +} as const; + +function ErrorAlert({ error }: { error: Error | null }) { + return error ? : null; +} + +function ConnectionStatus({ status }: { status: SmtpConnectionStatus }) { + const presentation = statusPresentation[status]; + return {presentation.label}; +} + +function createSettings( + connection: SmtpConnection, +): SmtpSettings { + return { + displayName: connection.displayName, + provider: connection.provider, + host: connection.host, + port: connection.port, + security: connection.security, + username: connection.username ?? undefined, + password: "", + }; +} + +function ConnectionsPage({ sdk }: { sdk: AdminAppPageProps["sdk"] }) { + const { styles } = useStyles(); + const query = useSmtpConnections(sdk); + const actions = useSmtpConnectionActions(sdk); + const connections = useMemo( + () => + [...query.connections].sort( + (left, right) => + Number(left.status === SmtpConnectionStatus.Disconnected) - + Number(right.status === SmtpConnectionStatus.Disconnected), + ), + [query.connections], + ); + + const openConnection = (connectionId: string) => { + sdk.navigation.openAppPath(`connections/${connectionId}`); + }; + + const addConnection = async () => { + try { + const result = await sdk.modals.openApp< + CreateSmtpConnectionModalPayload, + CreateSmtpConnectionModalResult + >(CREATE_SMTP_CONNECTION_MODAL_ID, { presets: query.presets }); + if (result.status !== "submitted") return; + + sdk.notifications.success("SMTP connection added"); + openConnection(result.data.connectionId); + } catch (error) { + sdk.notifications.error( + "Unable to add SMTP connection", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const activateConnection = async (connection: SmtpConnection) => { + try { + await actions.activateConnection(connection.id); + sdk.notifications.success("SMTP connection activated"); + await query.refetch(); + } catch (error) { + sdk.notifications.error( + "Unable to activate SMTP connection", + error instanceof Error ? error.message : undefined, + ); + } + }; + + return ( + void addConnection()} + > + Add connection + + } + > +
    + + + + + {connections.length > 0 ? ( + { + const isDisconnected = + connection.status === SmtpConnectionStatus.Disconnected; + const isInactive = + connection.status === SmtpConnectionStatus.Inactive; + + return ( + +
    openConnection(connection.id) + } + onKeyDown={ + isDisconnected + ? undefined + : (event) => { + if ( + event.key === "Enter" || + event.key === " " + ) { + openConnection(connection.id); + } + } + } + > +
    + + {connection.displayName} + + + Created{" "} + {dateFormatter.format( + new Date(connection.createdAt), + )} + +
    + + {isInactive ? ( + { + void activateConnection(connection); + }, + }, + ], + }} + trigger={["click"]} + > +
    +
    + ); + }} + /> + ) : query.loading ? null : ( + + )} +
    +
    +
    +
    + ); +} + +function ConnectionDetailPage({ + sdk, + connectionId, +}: { + sdk: AdminAppPageProps["sdk"]; + connectionId: string; +}) { + const { styles } = useStyles(); + const query = useSmtpConnection(sdk, connectionId); + const actions = useSmtpConnectionActions(sdk); + const connection = query.connection; + + const updateConnection = async (settings: SmtpSettings) => { + if (!connection) return false; + try { + await actions.updateConnection({ + connectionId: connection.id, + ...settings, + }); + sdk.notifications.success("SMTP connection updated"); + await query.refetch(); + return true; + } catch (error) { + sdk.notifications.error( + "Unable to update SMTP connection", + error instanceof Error ? error.message : undefined, + ); + return false; + } + }; + + const activateConnection = async () => { + if (!connection) return; + try { + await actions.activateConnection(connection.id); + sdk.notifications.success("SMTP connection activated"); + await query.refetch(); + } catch (error) { + sdk.notifications.error( + "Unable to activate SMTP connection", + error instanceof Error ? error.message : undefined, + ); + } + }; + + const disconnectConnection = async () => { + if (!connection) return; + + try { + const result = await sdk.modals.openApp< + SmtpDisconnectModalPayload, + SmtpDisconnectModalResult + >(SMTP_DISCONNECT_MODAL_ID, { + connectionId: connection.id, + displayName: connection.displayName, + }); + if (result.status === "submitted") { + sdk.notifications.success("SMTP connection disconnected"); + sdk.navigation.openAppPath("connections"); + } + } catch (error) { + sdk.notifications.error( + "Unable to open disconnect SMTP connection", + error instanceof Error ? error.message : undefined, + ); + } + }; + + return ( + sdk.navigation.openAppPath("connections")} + title={ + connection?.displayName ?? ( + + ) + } + > + {query.loading && !connection ? ( + + ) : ( + +
    + + {connection ? ( + <> + + { + void activateConnection(); + }, + }, + ], + }} + trigger={["click"]} + > + +
    + + + ) : query.loading ? null : ( + + )} + +
    + )} +
    + ); +} + +export default function SmtpAdminPage({ + sdk, + route, +}: AdminAppPageProps) { + const routeSegments = route.appPath.split("/").filter(Boolean); + const connectionId = + routeSegments[0] === "connections" && routeSegments.length > 1 + ? routeSegments.slice(1).join("/") + : null; + + if (connectionId) { + return ( + + ); + } + + return ; +} diff --git a/admin/src/domains/auth/AUTH_IMPLEMENTATION_PLAN.md b/admin/src/domains/auth/AUTH_IMPLEMENTATION_PLAN.md index 453e1a7b2..3c94d2d29 100644 --- a/admin/src/domains/auth/AUTH_IMPLEMENTATION_PLAN.md +++ b/admin/src/domains/auth/AUTH_IMPLEMENTATION_PLAN.md @@ -459,7 +459,7 @@ export default function SignInPage() { import { Controller, type UseFormReturn } from 'react-hook-form'; import { Button, Input, Typography, Checkbox, Flex } from 'antd'; -import { MailOutlined, LockOutlined } from '@ant-design/icons'; +import { LuMail, LuLockKeyhole } from 'react-icons/lu'; import Link from 'next/link'; import type { SignInFormValues } from '../../schemas'; import { FormField, FormError } from '../../components/form'; @@ -492,7 +492,7 @@ export function SignInForm({ form, onSubmit, loading }: SignInFormProps) { render={({ field }) => ( } + prefix={} placeholder="email@example.com" autoComplete="email" status={errors.email ? 'error' : undefined} @@ -509,7 +509,7 @@ export function SignInForm({ form, onSubmit, loading }: SignInFormProps) { render={({ field }) => ( } + prefix={} placeholder="Enter your password" autoComplete="current-password" status={errors.password ? 'error' : undefined} @@ -856,7 +856,7 @@ export function AuthGuard({ children }: AuthGuardProps) { import { useMemo } from 'react'; import { Typography, Progress, Flex } from 'antd'; -import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; +import { LuCheck, LuX } from 'react-icons/lu'; import { useStyles } from './password-strength.styles'; interface PasswordStrengthProps { @@ -928,9 +928,9 @@ export function PasswordStrength({ password, showRequirements = true }: Password className={req.met ? styles.requirementMet : styles.requirementUnmet} > {req.met ? ( - + ) : ( - + )} } + icon={} title={title} subTitle={reason || "You don't have permission to access this resource."} extra={ diff --git a/admin/src/domains/auth/context/index.ts b/admin/src/domains/auth/context/index.ts index 03b368720..20df2316a 100644 --- a/admin/src/domains/auth/context/index.ts +++ b/admin/src/domains/auth/context/index.ts @@ -1,12 +1 @@ export { AuthContext } from "./auth-context"; -export type { - AuthContextValue, - AuthError, - SignInInput, - SignInResult, - SignUpInput, - SignUpResult, - SignOutOptions, - SignOutResult, - TokenRefreshResult, -} from "./types"; diff --git a/admin/src/domains/auth/hooks/index.ts b/admin/src/domains/auth/hooks/index.ts index 28119ce6b..b2ddd6940 100644 --- a/admin/src/domains/auth/hooks/index.ts +++ b/admin/src/domains/auth/hooks/index.ts @@ -8,20 +8,14 @@ export { useAuth } from "./use-auth"; // Authentication operation hooks export { useSignIn } from "./use-sign-in"; -export type { UseSignInReturn } from "./use-sign-in"; export { useSignUp } from "./use-sign-up"; -export type { UseSignUpReturn } from "./use-sign-up"; export { useSignOut } from "./use-sign-out"; -export type { UseSignOutReturn } from "./use-sign-out"; export { useTokenRefresh } from "./use-token-refresh"; -export type { UseTokenRefreshReturn } from "./use-token-refresh"; // Session/user hooks export { useCurrentUser } from "./use-current-user"; -export type { UseCurrentUserReturn } from "./use-current-user"; export { useSession } from "./use-session"; -export type { UseSessionReturn } from "./use-session"; diff --git a/admin/src/domains/auth/index.ts b/admin/src/domains/auth/index.ts index d4cdc6648..bc4515c75 100644 --- a/admin/src/domains/auth/index.ts +++ b/admin/src/domains/auth/index.ts @@ -8,17 +8,6 @@ export { AuthGuard, ProfileCompletionGuard, SessionProvider } from "./components // Context export { AuthContext } from "./context"; -export type { - AuthContextValue, - AuthError, - SignInInput, - SignInResult, - SignUpInput, - SignUpResult, - SignOutOptions, - SignOutResult, - TokenRefreshResult, -} from "./context"; // Hooks export { @@ -30,14 +19,6 @@ export { useCurrentUser, useSession, } from "./hooks"; -export type { - UseSignInReturn, - UseSignUpReturn, - UseSignOutReturn, - UseTokenRefreshReturn, - UseCurrentUserReturn, - UseSessionReturn, -} from "./hooks"; // Schemas export { @@ -48,7 +29,6 @@ export { signInSchema, signUpSchema, } from "./schemas"; -export type { SignInFormValues, SignUpFormValues } from "./schemas"; // GraphQL operations export { @@ -68,7 +48,5 @@ export { getErrorMessage, createNetworkError, } from "./utils"; -export type { MapErrorsResult } from "./utils"; // Types -export type * from "./types"; diff --git a/admin/src/domains/auth/schemas/index.ts b/admin/src/domains/auth/schemas/index.ts index 6debda914..64c9bfc93 100644 --- a/admin/src/domains/auth/schemas/index.ts +++ b/admin/src/domains/auth/schemas/index.ts @@ -8,8 +8,6 @@ export { // Sign-in schema export { signInSchema } from "./sign-in.schema"; -export type { SignInFormValues } from "./sign-in.schema"; // Sign-up schema export { signUpSchema } from "./sign-up.schema"; -export type { SignUpFormValues } from "./sign-up.schema"; diff --git a/admin/src/domains/auth/sign-in/components/sign-in-form.tsx b/admin/src/domains/auth/sign-in/components/sign-in-form.tsx index 11ee1efff..786f7e800 100644 --- a/admin/src/domains/auth/sign-in/components/sign-in-form.tsx +++ b/admin/src/domains/auth/sign-in/components/sign-in-form.tsx @@ -2,10 +2,10 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { Button, Input, Typography, Checkbox, Flex } from "antd"; -import { LockOutlined } from "@ant-design/icons"; +import { LuLockKeyhole as LockOutlined } from "react-icons/lu"; import { createStyles } from "antd-style"; import Link from "next/link"; -import type { SignInFormValues } from "../../schemas"; +import type { SignInFormValues } from "../../schemas/sign-in.schema"; const useStyles = createStyles(({ token }) => ({ card: { @@ -94,6 +94,7 @@ export function SignInForm({ form, onSubmit, loading }: SignInFormProps) { render={({ field }) => ( ( } placeholder="Enter your password" autoComplete="current-password" @@ -152,6 +154,7 @@ export function SignInForm({ form, onSubmit, loading }: SignInFormProps) { +
    Reason code * { setReasonCode(event.target.value); change(); }} style={{ marginTop: 8 }} />
    Priority
    { setPriority(next ?? 0); change(); }} />
    +
    Assigned principal { setAssignedTo(event.target.value); change(); }} style={{ marginTop: 8 }} />
    +
    Due at { setDueAt(event.target.value); change(); }} style={{ marginTop: 8 }} />
    + {current ? { setResolutionCode(event.target.value); change(); }} /> { setResolutionNote(event.target.value); change(); }} /> : null} + ; +} diff --git a/admin/src/domains/customer-content/management/cases/filter-schema.ts b/admin/src/domains/customer-content/management/cases/filter-schema.ts new file mode 100644 index 000000000..3227b1498 --- /dev/null +++ b/admin/src/domains/customer-content/management/cases/filter-schema.ts @@ -0,0 +1,12 @@ +import { FilterType, dateOperators, enumOperators, numberOperators, stringOperators } from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { ReviewModerationCaseStatus } from "@/graphql/types"; + +export const filterSchema: IFilterSchema[] = [ + { key: "status", label: "Status", description: "Filter by case status", type: FilterType.Enum, operators: enumOperators, payloadKey: "status", options: Object.values(ReviewModerationCaseStatus).map((value) => ({ label: value.toLowerCase().replaceAll("_", " "), value })) }, + { key: "priority", label: "Priority", description: "Filter by moderation priority", type: FilterType.Number, operators: numberOperators, payloadKey: "priority" }, + { key: "reasonCode", label: "Reason code", description: "Filter by case reason code", type: FilterType.String, operators: stringOperators, payloadKey: "reasonCode" }, + { key: "assignee", label: "Assigned principal", description: "Filter by assigned principal ID", type: FilterType.String, operators: stringOperators, payloadKey: "assignedToPrincipalId" }, + { key: "dueAt", label: "Due date", description: "Filter by due date", type: FilterType.DateRange, operators: dateOperators, payloadKey: "dueAt" }, + { key: "createdAt", label: "Created date", description: "Filter by case creation date", type: FilterType.DateRange, operators: dateOperators, payloadKey: "createdAt" }, +]; diff --git a/admin/src/domains/customer-content/management/cases/index.ts b/admin/src/domains/customer-content/management/cases/index.ts new file mode 100644 index 000000000..997d3b2d7 --- /dev/null +++ b/admin/src/domains/customer-content/management/cases/index.ts @@ -0,0 +1 @@ +export { ModerationCaseModal } from "./case-modal"; diff --git a/admin/src/domains/customer-content/management/cases/page-config.ts b/admin/src/domains/customer-content/management/cases/page-config.ts new file mode 100644 index 000000000..e23647150 --- /dev/null +++ b/admin/src/domains/customer-content/management/cases/page-config.ts @@ -0,0 +1,27 @@ +import type { SortFieldMapping, UsePageConfigReturn } from "@/hooks"; +import type { ApiReviewModerationCaseOrderByInput, ApiReviewModerationCaseWhereInput } from "@/graphql/types"; +import { ReviewModerationCaseOrderField } from "@/graphql/types"; +import type { ModerationCasesQueryVariables } from "../hooks"; + +export const moderationCaseSortFieldMapping: SortFieldMapping = { + reasonCode: ReviewModerationCaseOrderField.ReasonCode, + priority: ReviewModerationCaseOrderField.Priority, + status: ReviewModerationCaseOrderField.Status, + assignedToPrincipalId: ReviewModerationCaseOrderField.AssignedToPrincipalId, + dueAt: ReviewModerationCaseOrderField.DueAt, + createdAt: ReviewModerationCaseOrderField.CreatedAt, +}; + +export const buildModerationCaseSearchCondition = (search: string): Partial => ({ + _or: [ + { reasonCode: { _containsi: search } }, + { assignedToPrincipalId: { _containsi: search } }, + { resolutionCode: { _containsi: search } }, + ], +}); + +export function buildModerationCasesQueryVariables( + pageConfig: Pick, "first" | "after" | "last" | "before" | "where" | "orderBy">, +): ModerationCasesQueryVariables { + return { ...pageConfig, where: pageConfig.where ?? null, orderBy: pageConfig.orderBy as ApiReviewModerationCaseOrderByInput[] | undefined }; +} diff --git a/admin/src/domains/customer-content/management/cases/page.tsx b/admin/src/domains/customer-content/management/cases/page.tsx new file mode 100644 index 000000000..bad330b14 --- /dev/null +++ b/admin/src/domains/customer-content/management/cases/page.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, Button, Flex, Tag, Typography } from "antd"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { AllCommunityModule, GridStateModule, ModuleRegistry, type ColDef } from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { ApiReviewModerationCase, ApiReviewModerationCaseWhereInput } from "@/graphql/types"; +import { ReviewModerationCaseOrderField, ReviewModerationCaseStatus } from "@/graphql/types"; +import { useUgcNavigation } from "@/domains/customer-content/use-ugc-navigation"; +import { useModerationCaseModal } from "../modals"; +import { useModerationCases } from "../hooks"; +import { filterSchema } from "./filter-schema"; +import { buildModerationCaseSearchCondition, buildModerationCasesQueryVariables, moderationCaseSortFieldMapping } from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); +const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }); + +function ContentCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return {data.content.body}{data.content.author.displayName}; +} + +export default function ModerationCasesPage() { + const { backToUgc } = useUgcNavigation(); + const theme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig({ + gridRef, storageKey: "moderation-cases-grid-state", filterSchema, sortFieldMapping: moderationCaseSortFieldMapping, + defaultSort: [{ colId: "createdAt", sort: "desc" }], defaultPageSize: 10, pageSizeOptions: [10, 20, 50], buildSearchCondition: buildModerationCaseSearchCondition, + }); + const variables = useMemo(() => buildModerationCasesQueryVariables(pageConfig), [pageConfig.first, pageConfig.after, pageConfig.last, pageConfig.before, pageConfig.where, pageConfig.orderBy]); + const { moderationCases, totalCount, pageInfo, loading, error, refetch } = useModerationCases(variables); + const { push } = useModerationCaseModal(); + const next = useCallback(() => { if (pageInfo?.endCursor) pageConfig.goToNextPage(pageInfo.endCursor); }, [pageConfig, pageInfo?.endCursor]); + const prev = useCallback(() => { if (pageInfo?.startCursor) pageConfig.goToPrevPage(pageInfo.startCursor); }, [pageConfig, pageInfo?.startCursor]); + const columnDefs = useMemo[]>(() => [ + { headerName: "Content", colId: "content", cellRenderer: ContentCell, minWidth: 340, flex: 2, sortable: false }, + { headerName: "Reason", field: "reasonCode", minWidth: 180 }, + { headerName: "Priority", field: "priority", width: 105 }, + { headerName: "Status", field: "status", width: 130, cellRenderer: ({ value }: CustomCellRendererProps) => {String(value).toLowerCase().replaceAll("_", " ")} }, + { headerName: "Assignee", field: "assignedToPrincipalId", minWidth: 180, valueFormatter: ({ value }) => value ?? "Unassigned" }, + { headerName: "Due", field: "dueAt", minWidth: 180, cellRenderer: ({ value }: CustomCellRendererProps) => {value ? dateFormatter.format(new Date(value)) : "—"} }, + { headerName: "Created", field: "createdAt", minWidth: 180, cellRenderer: ({ value }: CustomCellRendererProps) => {value ? dateFormatter.format(new Date(value)) : ""} }, + ], []); + const defaultColDef = useMemo>(() => ({ resizable: true, sortable: true, comparator: () => 0, cellStyle: { display: "flex", alignItems: "center" } }), []); + + return } onClick={() => push({ onSaved: refetch })}>Create moderation case}> + } /> +
    + {error ? : null} +
    ref={gridRef} theme={theme} rowData={moderationCases} loading={loading} columnDefs={columnDefs} defaultColDef={defaultColDef} getRowId={({ data }) => data.id} rowHeight={68} suppressCellFocus suppressMovableColumns rowStyle={{ cursor: "pointer" }} onRowClicked={({ data }) => data && push({ moderationCase: data, onSaved: refetch })} onSortChanged={pageConfig.onSortChanged} initialState={pageConfig.gridStateProps.initialState} onStateUpdated={pageConfig.gridStateProps.onStateUpdated} />
    + +
    +
    ; +} diff --git a/admin/src/domains/customer-content/management/content-picker-config.ts b/admin/src/domains/customer-content/management/content-picker-config.ts new file mode 100644 index 000000000..43d374209 --- /dev/null +++ b/admin/src/domains/customer-content/management/content-picker-config.ts @@ -0,0 +1,35 @@ +"use client"; + +import { createElement, useMemo } from "react"; +import { LuFileText as FileTextOutlined } from "react-icons/lu"; +import type { ColDef } from "ag-grid-community"; +import type { ApiReviewContentOrderByInput, ApiReviewContentWhereInput } from "@/graphql/types"; +import { ReviewContentOrderField } from "@/graphql/types"; +import { EntityCellRenderer, StatusCellRenderer } from "@/shared/components/entity-picker-modal/cell-renderers"; +import { registerEntityPickerConfig } from "@/shared/components/entity-picker-modal/configs"; +import type { IEntityPickerConfig, IEntityPickerDataResult, IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import { useModerationContents } from "./hooks"; + +function useContentPickerData(options: { pageSize: number; first?: number; after?: string | null; last?: number; before?: string | null; where?: object | null; orderBy?: object[] | null; excludeIds: string[] }): IEntityPickerDataResult { + const where = useMemo(() => { + const conditions: ApiReviewContentWhereInput[] = []; + if (options.where) conditions.push(options.where as ApiReviewContentWhereInput); + if (options.excludeIds.length) conditions.push({ id: { _notIn: options.excludeIds } }); + return conditions.length > 1 ? { _and: conditions } : conditions[0] ?? null; + }, [options.excludeIds, options.where]); + const query = useModerationContents({ first: options.first, after: options.after, last: options.last, before: options.before, where, orderBy: options.orderBy as ApiReviewContentOrderByInput[] | null }); + return { data: query.contents.map((node) => ({ id: node.id, title: node.title || `${node.kind}: ${node.body.slice(0, 100)}`, status: node.status })), isLoading: query.loading, error: query.error, pagination: { total: query.totalCount, pageSize: options.pageSize, hasNext: query.pageInfo?.hasNextPage ?? false, hasPrev: query.pageInfo?.hasPreviousPage ?? false, startCursor: query.pageInfo?.startCursor, endCursor: query.pageInfo?.endCursor } }; +} + +const columns: ColDef[] = [ + { headerName: "Content", field: "title", cellRenderer: EntityCellRenderer, cellRendererParams: { fallbackIcon: createElement(FileTextOutlined) }, flex: 1, minWidth: 380 }, + { headerName: "Status", field: "status", cellRenderer: StatusCellRenderer, width: 130, sortable: false }, +]; + +export const contentPickerConfig: IEntityPickerConfig = { + entityType: "review-content", entityName: "Content", entityNamePlural: "Content", filterSchema: [], searchEnabled: true, columns, + pageConfig: { storageKey: "review-content-picker-grid-state", sortFieldMapping: { title: ReviewContentOrderField.Title }, buildSearchCondition: (search) => ({ _or: [{ title: { _containsi: search } }, { body: { _containsi: search } }, { authorDisplayName: { _containsi: search } }] }), defaultPageSize: 20, pageSizeOptions: [20, 50] }, + useData: useContentPickerData, getRowId: (entity) => entity.id, +}; + +registerEntityPickerConfig(contentPickerConfig); diff --git a/admin/src/domains/customer-content/management/criteria/criterion-modal.tsx b/admin/src/domains/customer-content/management/criteria/criterion-modal.tsx new file mode 100644 index 000000000..d49191acb --- /dev/null +++ b/admin/src/domains/customer-content/management/criteria/criterion-modal.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Alert, App, Button, Flex, Input, InputNumber, Select, Switch, Tag, Typography } from "antd"; +import { LuTrash2 as DeleteOutlined, LuPlus as PlusOutlined, LuShoppingBag as ShoppingOutlined, LuTags as TagsOutlined } from "react-icons/lu"; +import { ReviewRatingCriterionTargetType } from "@/graphql/types"; +import { shopLocales } from "@/defs/localization"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useEntityPicker } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import "@/shared/components/entity-picker-modal/configs/product-picker-config"; +import "@/shared/components/entity-picker-modal/configs/category-picker-config"; +import { useManagementMutations } from "../hooks"; +import type { RatingCriterionModalPayload } from "../modals"; + +type Translation = { locale: string; title: string; description: string }; +type Assignment = { targetType: ReviewRatingCriterionTargetType; targetId: string; title: string; isRequiredOverride?: boolean | null; sortIndexOverride?: number | null }; + +export function RatingCriterionModal() { + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as RatingCriterionModalPayload; const current = value.criterion; const mutations = useManagementMutations(); + const [code, setCode] = useState(current?.code ?? ""); const [title, setTitle] = useState(current?.defaultTitle ?? ""); + const [description, setDescription] = useState(current?.defaultDescription ?? ""); const [weight, setWeight] = useState(current?.weight ?? 1); + const [required, setRequired] = useState(current?.isRequired ?? false); const [active, setActive] = useState(current?.isActive ?? true); + const [allProducts, setAllProducts] = useState(current?.appliesToAllProducts ?? true); const [sortIndex, setSortIndex] = useState(current?.sortIndex ?? 0); + const [translations, setTranslations] = useState(current?.translations.map((item) => ({ locale: item.locale, title: item.title, description: item.description ?? "" })) ?? []); + const [assignments, setAssignments] = useState(current?.assignments.map((item) => ({ targetType: item.targetType, targetId: item.targetId, title: item.target.title, isRequiredOverride: item.isRequiredOverride, sortIndexOverride: item.sortIndexOverride })) ?? []); + const [error, setError] = useState(null); const [dirty, setLocalDirty] = useState(false); + useEffect(() => setDirty(dirty), [dirty, setDirty]); const change = () => setLocalDirty(true); + const products = useEntityPicker({ entityType: "product", selectionMode: "multi", initialSelection: assignments.filter((item) => item.targetType === ReviewRatingCriterionTargetType.Product).map((item) => item.targetId), allowEmptySelection: true, onConfirm: (items) => { setAssignments((existing) => [...existing.filter((item) => item.targetType !== ReviewRatingCriterionTargetType.Product), ...items.map((item) => ({ targetType: ReviewRatingCriterionTargetType.Product, targetId: item.id, title: item.title }))]); change(); } }); + const categories = useEntityPicker({ entityType: "category", selectionMode: "multi", initialSelection: assignments.filter((item) => item.targetType === ReviewRatingCriterionTargetType.Category).map((item) => item.targetId), allowEmptySelection: true, onConfirm: (items) => { setAssignments((existing) => [...existing.filter((item) => item.targetType !== ReviewRatingCriterionTargetType.Category), ...items.map((item) => ({ targetType: ReviewRatingCriterionTargetType.Category, targetId: item.id, title: item.title }))]); change(); } }); + const canSave = useMemo(() => code.trim().length > 0 && title.trim().length > 0 && weight >= 0, [code, title, weight]); + const save = async () => { + setError(null); + const definition = { code: code.trim(), defaultTitle: title.trim(), defaultDescription: description.trim() || null, weight, isRequired: required, isActive: active, sortIndex }; + const translationInputs = translations.filter((item) => item.locale && item.title.trim()).map((item) => ({ locale: item.locale, title: item.title.trim(), description: item.description.trim() || null })); + const assignmentInputs = allProducts ? [] : assignments.map(({ targetType, targetId, isRequiredOverride, sortIndexOverride }) => ({ targetType, targetId, isRequiredOverride, sortIndexOverride })); + const result = current + ? await mutations.updateCriterion(current.id, current.updatedAt, { definition, applicability: { appliesToAllProducts: allProducts }, translations: translationInputs, assignments: assignmentInputs }) + : await mutations.createCriterion({ ...definition, appliesToAllProducts: allProducts, translations: translationInputs, assignments: assignmentInputs }); + if (result.errors.length) return setError(result.errors.map((item) => item.message).join(" ")); + await value.onSaved?.(); setDirty(false); message.success(current ? "Criterion updated" : "Criterion created"); forcePop(); + }; + const remove = async () => { + if (!current) return; + const result = await mutations.deleteCriterion({ id: current.id, expectedUpdatedAt: current.updatedAt }); + if (result.errors.length) return setError(result.errors.map((item) => item.message).join(" ")); + await value.onSaved?.(); message.success("Criterion deleted"); forcePop(); + }; + const updateTranslation = (index: number, field: keyof Translation, next: string) => { setTranslations((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, [field]: next } : item)); change(); }; + return }> + {error ? : null} +
    Code * { setCode(event.target.value); change(); }} style={{ marginTop: 8 }} />
    Title * { setTitle(event.target.value); change(); }} style={{ marginTop: 8 }} />
    Description { setDescription(event.target.value); change(); }} rows={3} style={{ marginTop: 8 }} />
    Weight
    { setWeight(next ?? 1); change(); }} />
    Sort index
    { setSortIndex(next ?? 0); change(); }} />
    { setRequired(next); change(); }} />Required { setActive(next); change(); }} />Active
    + } onClick={() => { setTranslations((items) => [...items, { locale: "", title: "", description: "" }]); change(); }}>Add} />{translations.map((item, index) => updateTranslation(index, "title", event.target.value)} /> updateTranslation(index, "description", event.target.value)} />{assignments.map((item) => {item.title} · {item.targetType.toLowerCase()})} : null} + {current ? : null} +
    ; +} diff --git a/admin/src/domains/customer-content/management/criteria/index.ts b/admin/src/domains/customer-content/management/criteria/index.ts new file mode 100644 index 000000000..d55fa70be --- /dev/null +++ b/admin/src/domains/customer-content/management/criteria/index.ts @@ -0,0 +1 @@ +export { RatingCriterionModal } from "./criterion-modal"; diff --git a/admin/src/domains/customer-content/management/external/external-reference-modal.tsx b/admin/src/domains/customer-content/management/external/external-reference-modal.tsx new file mode 100644 index 000000000..86a7c03de --- /dev/null +++ b/admin/src/domains/customer-content/management/external/external-reference-modal.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Alert, App, Button, Collapse, Descriptions, Dropdown, Flex, Input, Select, Typography } from "antd"; +import { LuEllipsis as MoreOutlined, LuFileSearch as FileSearchOutlined, LuTrash2 as DeleteOutlined } from "react-icons/lu"; +import { ReviewExternalSyncDirection, ReviewExternalSyncStatus } from "@/graphql/types"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useManagementMutations } from "../hooks"; +import type { ExternalReferenceModalPayload } from "../modals"; +import { useEntityPicker } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import { formatDetailDate } from "@/domains/inventory/utils/format-detail-date"; +import "../content-picker-config"; + +const humanizeEnum = (value: string) => value.toLowerCase().split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); +const errorMessage = (cause: unknown, fallback: string) => cause instanceof Error ? cause.message : fallback; + +export function ExternalReferenceModal() { + const { message, modal } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as ExternalReferenceModalPayload; + const current = value.externalReference; + const currentContent = current && "content" in current ? current.content : null; + const mutations = useManagementMutations(); + const [contentId, setContentId] = useState(currentContent?.id ?? value.contentId ?? ""); + const [contentLabel, setContentLabel] = useState(value.contentLabel ?? currentContent?.body ?? value.contentId ?? ""); + const [system, setSystem] = useState(current?.externalSystem ?? ""); + const [type, setType] = useState(current?.externalType ?? "REVIEW"); + const [externalId, setExternalId] = useState(current?.externalId ?? ""); + const [url, setUrl] = useState(current?.externalUrl ?? ""); + const [direction, setDirection] = useState(current?.direction ?? ReviewExternalSyncDirection.Bidirectional); + const [status, setStatus] = useState(current?.syncStatus ?? ReviewExternalSyncStatus.Pending); + const [etag, setEtag] = useState(current?.etag ?? ""); + const [checksum, setChecksum] = useState(current?.contentChecksum ?? ""); + const [metadata, setMetadata] = useState(JSON.stringify(current?.metadata ?? {}, null, 2)); + const [dirty, setLocalDirty] = useState(false); + const [error, setError] = useState(null); + useEffect(() => setDirty(dirty), [dirty, setDirty]); + const change = () => setLocalDirty(true); + + const contentPicker = useEntityPicker({ + entityType: "review-content", + selectionMode: "single", + initialSelection: contentId ? [contentId] : [], + onConfirm: (items, ids) => { + setContentId(ids[0] ?? ""); + setContentLabel(items[0]?.title ?? ids[0] ?? ""); + change(); + }, + }); + + const parseMetadata = () => { + try { + const parsed = JSON.parse(metadata); + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error(); + return parsed; + } catch { + setError("Metadata must be a valid JSON object."); + return null; + } + }; + + const save = async () => { + setError(null); + const parsed = parseMetadata(); + if (!parsed) return; + if (!contentId.trim() || !system.trim() || !type.trim() || !externalId.trim()) { + setError("Content, system, type and external ID are required."); + return; + } + try { + const result = current + ? await mutations.updateExternalReference(current.id, current.updatedAt, { + identity: { externalSystem: system.trim(), externalType: type.trim(), externalId: externalId.trim(), externalUrl: url.trim() || null }, + sync: { direction, status, etag: etag.trim() || null, contentChecksum: checksum.trim() || null, metadata: parsed }, + }) + : await mutations.createExternalReference({ + contentId: contentId.trim(), + externalSystem: system.trim(), + externalType: type.trim(), + externalId: externalId.trim(), + externalUrl: url.trim() || null, + direction, + metadata: parsed, + }); + if (result.errors.length) { + setError(result.errors.map((item) => item.message).join(" ")); + return; + } + await value.onSaved?.(); + setDirty(false); + message.success(current ? "External reference updated" : "External reference created"); + forcePop(); + } catch (cause) { + setError(errorMessage(cause, "Unable to save external reference.")); + } + }; + + const remove = async () => { + if (!current) return; + const confirmed = await modal.confirm({ + title: "Delete external reference?", + content: "The external reference will be removed from this review.", + okText: "Delete", + okButtonProps: { danger: true }, + }); + if (!confirmed) return; + try { + const result = await mutations.deleteExternalReference({ id: current.id, expectedUpdatedAt: current.updatedAt }); + if (result.errors.length) { + setError(result.errors.map((item) => item.message).join(" ")); + return; + } + await value.onSaved?.(); + message.success("External reference deleted"); + forcePop(); + } catch (cause) { + setError(errorMessage(cause, "Unable to delete external reference.")); + } + }; + + return ( + , danger: true, "data-testid": "external-reference-delete", onClick: () => void remove() }] }}> + + + + +
    + External system * + { setSystem(event.target.value); change(); }} style={{ marginTop: 8 }} /> +
    +
    + Type * + { setType(event.target.value); change(); }} style={{ marginTop: 8 }} /> +
    +
    +
    + External ID * + { setExternalId(event.target.value); change(); }} style={{ marginTop: 8 }} /> +
    +
    + External URL + { setUrl(event.target.value); change(); }} style={{ marginTop: 8 }} /> +
    + + + + + +
    + Direction * + ({ value: item, label: humanizeEnum(item) }))} onChange={(next) => { setStatus(next); change(); }} style={{ width: "100%", marginTop: 8 }} /> +
    + ) : null} + {current ? ( + + ) : null} + + { setEtag(event.target.value); change(); }} /> + { setChecksum(event.target.value); change(); }} /> +
    + Metadata (JSON) + { setMetadata(event.target.value); change(); }} rows={8} style={{ marginTop: 8, fontFamily: "ui-monospace, SFMono-Regular, monospace" }} /> +
    +
    + ), + }]} + /> + +
    +
    + ); +} diff --git a/admin/src/domains/customer-content/management/external/filter-schema.ts b/admin/src/domains/customer-content/management/external/filter-schema.ts new file mode 100644 index 000000000..c9e3de4c5 --- /dev/null +++ b/admin/src/domains/customer-content/management/external/filter-schema.ts @@ -0,0 +1,14 @@ +import { FilterType, dateOperators, enumOperators, stringOperators } from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { ReviewExternalSyncDirection, ReviewExternalSyncStatus } from "@/graphql/types"; + +const enumOptions = (values: T[]) => values.map((value) => ({ label: value.toLowerCase().replaceAll("_", " "), value })); + +export const filterSchema: IFilterSchema[] = [ + { key: "syncStatus", label: "Status", description: "Filter by synchronization status", type: FilterType.Enum, operators: enumOperators, payloadKey: "syncStatus", options: enumOptions(Object.values(ReviewExternalSyncStatus)) }, + { key: "direction", label: "Direction", description: "Filter by synchronization direction", type: FilterType.Enum, operators: enumOperators, payloadKey: "direction", options: enumOptions(Object.values(ReviewExternalSyncDirection)) }, + { key: "externalSystem", label: "External system", description: "Filter by external system", type: FilterType.String, operators: stringOperators, payloadKey: "externalSystem" }, + { key: "externalType", label: "External type", description: "Filter by external content type", type: FilterType.String, operators: stringOperators, payloadKey: "externalType" }, + { key: "lastSyncedAt", label: "Last sync date", description: "Filter by last successful sync date", type: FilterType.DateRange, operators: dateOperators, payloadKey: "lastSyncedAt" }, + { key: "updatedAt", label: "Updated date", description: "Filter by reference update date", type: FilterType.DateRange, operators: dateOperators, payloadKey: "updatedAt" }, +]; diff --git a/admin/src/domains/customer-content/management/external/index.ts b/admin/src/domains/customer-content/management/external/index.ts new file mode 100644 index 000000000..eeb4a6d0b --- /dev/null +++ b/admin/src/domains/customer-content/management/external/index.ts @@ -0,0 +1 @@ +export { ExternalReferenceModal } from "./external-reference-modal"; diff --git a/admin/src/domains/customer-content/management/external/page-config.ts b/admin/src/domains/customer-content/management/external/page-config.ts new file mode 100644 index 000000000..7b3a56cee --- /dev/null +++ b/admin/src/domains/customer-content/management/external/page-config.ts @@ -0,0 +1,28 @@ +import type { SortFieldMapping, UsePageConfigReturn } from "@/hooks"; +import type { ApiReviewContentExternalReferenceOrderByInput, ApiReviewContentExternalReferenceWhereInput } from "@/graphql/types"; +import { ReviewContentExternalReferenceOrderField } from "@/graphql/types"; +import type { ExternalReferencesQueryVariables } from "../hooks"; + +export const externalReferenceSortFieldMapping: SortFieldMapping = { + externalSystem: ReviewContentExternalReferenceOrderField.ExternalSystem, + externalType: ReviewContentExternalReferenceOrderField.ExternalType, + externalId: ReviewContentExternalReferenceOrderField.ExternalId, + direction: ReviewContentExternalReferenceOrderField.Direction, + syncStatus: ReviewContentExternalReferenceOrderField.SyncStatus, + lastSyncedAt: ReviewContentExternalReferenceOrderField.LastSyncedAt, + updatedAt: ReviewContentExternalReferenceOrderField.UpdatedAt, +}; + +export const buildExternalReferenceSearchCondition = (search: string): Partial => ({ + _or: [ + { externalSystem: { _containsi: search } }, + { externalType: { _containsi: search } }, + { externalId: { _containsi: search } }, + ], +}); + +export function buildExternalReferencesQueryVariables( + pageConfig: Pick, "first" | "after" | "last" | "before" | "where" | "orderBy">, +): ExternalReferencesQueryVariables { + return { ...pageConfig, where: pageConfig.where ?? null, orderBy: pageConfig.orderBy as ApiReviewContentExternalReferenceOrderByInput[] | undefined }; +} diff --git a/admin/src/domains/customer-content/management/external/page.tsx b/admin/src/domains/customer-content/management/external/page.tsx new file mode 100644 index 000000000..642a9c0d4 --- /dev/null +++ b/admin/src/domains/customer-content/management/external/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, Button, Flex, Tag, Typography } from "antd"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { AllCommunityModule, GridStateModule, ModuleRegistry, type ColDef } from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { ApiReviewContentExternalReference, ApiReviewContentExternalReferenceWhereInput } from "@/graphql/types"; +import { ReviewContentExternalReferenceOrderField, ReviewExternalSyncStatus } from "@/graphql/types"; +import { useUgcNavigation } from "@/domains/customer-content/use-ugc-navigation"; +import { useExternalReferenceModal } from "../modals"; +import { useExternalReferences } from "../hooks"; +import { filterSchema } from "./filter-schema"; +import { buildExternalReferenceSearchCondition, buildExternalReferencesQueryVariables, externalReferenceSortFieldMapping } from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); +const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }); + +function ReferenceCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return {data.externalSystem} · {data.externalType}{data.externalId}; +} +function ContentCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return {data.content.body}{data.content.author.displayName}; +} + +export default function ExternalReferencesPage() { + const { backToUgc } = useUgcNavigation(); + const theme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig({ + gridRef, storageKey: "external-references-grid-state", filterSchema, sortFieldMapping: externalReferenceSortFieldMapping, + defaultSort: [{ colId: "updatedAt", sort: "desc" }], defaultPageSize: 10, pageSizeOptions: [10, 20, 50], buildSearchCondition: buildExternalReferenceSearchCondition, + }); + const variables = useMemo(() => buildExternalReferencesQueryVariables(pageConfig), [pageConfig.first, pageConfig.after, pageConfig.last, pageConfig.before, pageConfig.where, pageConfig.orderBy]); + const { externalReferences, totalCount, pageInfo, loading, error, refetch } = useExternalReferences(variables); + const { push } = useExternalReferenceModal(); + const next = useCallback(() => { if (pageInfo?.endCursor) pageConfig.goToNextPage(pageInfo.endCursor); }, [pageConfig, pageInfo?.endCursor]); + const prev = useCallback(() => { if (pageInfo?.startCursor) pageConfig.goToPrevPage(pageInfo.startCursor); }, [pageConfig, pageInfo?.startCursor]); + const columnDefs = useMemo[]>(() => [ + { headerName: "Reference", colId: "externalSystem", cellRenderer: ReferenceCell, minWidth: 240, flex: 1 }, + { headerName: "Content", colId: "content", cellRenderer: ContentCell, minWidth: 320, flex: 2, sortable: false }, + { headerName: "Type", field: "externalType", minWidth: 140 }, + { headerName: "Direction", field: "direction", width: 145, valueFormatter: ({ value }) => String(value).toLowerCase() }, + { headerName: "Status", field: "syncStatus", width: 125, cellRenderer: ({ value }: CustomCellRendererProps) => {String(value).toLowerCase()} }, + { headerName: "Last sync", field: "lastSyncedAt", minWidth: 180, cellRenderer: ({ value }: CustomCellRendererProps) => {value ? dateFormatter.format(new Date(value)) : "Never"} }, + { headerName: "Updated", field: "updatedAt", minWidth: 180, cellRenderer: ({ value }: CustomCellRendererProps) => {value ? dateFormatter.format(new Date(value)) : ""} }, + ], []); + const defaultColDef = useMemo>(() => ({ resizable: true, sortable: true, comparator: () => 0, cellStyle: { display: "flex", alignItems: "center" } }), []); + + return } onClick={() => push({ onSaved: refetch })}>Add reference}> + } /> +
    + {error ? : null} +
    ref={gridRef} theme={theme} rowData={externalReferences} loading={loading} columnDefs={columnDefs} defaultColDef={defaultColDef} getRowId={({ data }) => data.id} rowHeight={68} suppressCellFocus suppressMovableColumns rowStyle={{ cursor: "pointer" }} onRowClicked={({ data }) => data && push({ externalReference: data, onSaved: refetch })} onSortChanged={pageConfig.onSortChanged} initialState={pageConfig.gridStateProps.initialState} onStateUpdated={pageConfig.gridStateProps.onStateUpdated} />
    + +
    +
    ; +} diff --git a/admin/src/domains/customer-content/management/graphql.ts b/admin/src/domains/customer-content/management/graphql.ts new file mode 100644 index 000000000..fdf9b1cbc --- /dev/null +++ b/admin/src/domains/customer-content/management/graphql.ts @@ -0,0 +1,165 @@ +import { gql } from "@apollo/client"; + +const USER_ERRORS = gql`fragment CustomerContentUserErrorFields on GenericUserError { code field message }`; + +export const REVIEW_CONFIGURATION_QUERY = gql` + query AdminReviewConfiguration { + reviewsQuery { storeConfiguration { + id reviewsEnabled questionsEnabled guestReviewsEnabled guestQuestionsEnabled customerAnswersEnabled verifiedPurchaseRequired + reviewModerationMode questionModerationMode answerModerationMode reviewDuplicatePolicy reviewRequestsEnabled + reviewRequestDelayDays reviewRequestExpiryDays reviewEditWindowHours questionEditWindowHours answerEditWindowHours + maxReviewMediaCount maxAnswersPerQuestion revision createdAt updatedAt + } } + } +`; + +export const REVIEW_CONFIGURATION_UPDATE_MUTATION = gql` + mutation AdminReviewConfigurationUpdate($configurationId: ID!, $expectedRevision: Int!, $operations: ReviewStoreConfigurationUpdateInput) { + reviewsMutation { storeConfigurationUpdate(configurationId: $configurationId, expectedRevision: $expectedRevision, operations: $operations) { + configuration { id revision updatedAt } operationResults { errors { ...CustomerContentUserErrorFields } } userErrors { ...CustomerContentUserErrorFields } + } } + } ${USER_ERRORS} +`; + +export const RATING_CRITERIA_QUERY = gql` + query AdminRatingCriteria { + reviewsQuery { ratingCriteria(first: 100, orderBy: [{ field: sortIndex, direction: asc }]) { totalCount edges { node { + id code defaultTitle defaultDescription weight isRequired isActive appliesToAllProducts sortIndex createdAt updatedAt deletedAt + translations { locale title description createdAt updatedAt } + assignments { id targetType targetId isRequiredOverride sortIndexOverride createdAt target { ... on Product { id title } ... on Category { id title: name } } } + } } } } + } +`; + +export const RATING_CRITERION_CREATE_MUTATION = gql` + mutation AdminRatingCriterionCreate($input: ReviewRatingCriterionCreateInput!) { + reviewsMutation { ratingCriterionCreate(input: $input) { criterion { id updatedAt } userErrors { ...CustomerContentUserErrorFields } } } + } ${USER_ERRORS} +`; + +export const RATING_CRITERION_UPDATE_MUTATION = gql` + mutation AdminRatingCriterionUpdate($criterionId: ID!, $expectedUpdatedAt: DateTime!, $operations: ReviewRatingCriterionUpdateInput) { + reviewsMutation { ratingCriterionUpdate(criterionId: $criterionId, expectedUpdatedAt: $expectedUpdatedAt, operations: $operations) { + criterion { id updatedAt } operationResults { errors { ...CustomerContentUserErrorFields } } userErrors { ...CustomerContentUserErrorFields } + } } + } ${USER_ERRORS} +`; + +export const RATING_CRITERION_DELETE_MUTATION = gql` + mutation AdminRatingCriterionDelete($input: ReviewRatingCriterionDeleteInput!) { + reviewsMutation { ratingCriterionDelete(input: $input) { deletedCriterionId userErrors { ...CustomerContentUserErrorFields } } } + } ${USER_ERRORS} +`; + +export const MODERATION_CONTENTS_QUERY = gql` + query AdminModerationContents($first: Int, $after: String, $last: Int, $before: String, $where: ReviewContentWhereInput, $orderBy: [ReviewContentOrderByInput!]) { + reviewsQuery { contents(first: $first, after: $after, last: $last, before: $before, where: $where, orderBy: $orderBy, meta: { includeDeleted: true, includeRedacted: true }) { + totalCount edges { cursor node { id __typename kind title body locale status moderationNote revision redactedAt deletedAt createdAt updatedAt + author { type displayName email customer { id displayName } } + metrics { likeCount dislikeCount reportCount childCount mediaCount } + } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } + } } + } +`; + +export const CONTENT_REPORTS_QUERY = gql` + query AdminContentReports($first: Int, $after: String, $last: Int, $before: String, $where: ReviewContentReportWhereInput, $orderBy: [ReviewContentReportOrderByInput!]) { + reviewsQuery { contentReports(first: $first, after: $after, last: $last, before: $before, where: $where, orderBy: $orderBy) { totalCount edges { cursor node { + id reason details status assignedToPrincipalId resolutionNote resolvedByPrincipalId resolvedAt createdAt updatedAt + reporterCustomer { id displayName email } + content { id __typename body status author { displayName } } + } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } + } +`; + +export const CONTENT_REPORT_UPDATE_MUTATION = gql` + mutation AdminContentReportUpdate($contentReportId: ID!, $expectedUpdatedAt: DateTime!, $operations: ReviewContentReportUpdateInput) { + reviewsMutation { contentReportUpdate(contentReportId: $contentReportId, expectedUpdatedAt: $expectedUpdatedAt, operations: $operations) { + contentReport { id status assignedToPrincipalId resolutionNote updatedAt } operationResults { errors { ...CustomerContentUserErrorFields } } userErrors { ...CustomerContentUserErrorFields } + } } + } ${USER_ERRORS} +`; + +export const MODERATION_CASES_QUERY = gql` + query AdminModerationCases($first: Int, $after: String, $last: Int, $before: String, $where: ReviewModerationCaseWhereInput, $orderBy: [ReviewModerationCaseOrderByInput!]) { + reviewsQuery { moderationCases(first: $first, after: $after, last: $last, before: $before, where: $where, orderBy: $orderBy) { totalCount edges { cursor node { + id status priority reasonCode assignedToPrincipalId dueAt resolutionCode resolutionNote resolvedByPrincipalId resolvedAt createdAt updatedAt + content { id __typename body status author { displayName } } + } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } + } +`; + +export const MODERATION_CASE_CREATE_MUTATION = gql` + mutation AdminModerationCaseCreate($input: ReviewModerationCaseCreateInput!) { + reviewsMutation { moderationCaseCreate(input: $input) { moderationCase { id updatedAt } userErrors { ...CustomerContentUserErrorFields } } } + } ${USER_ERRORS} +`; + +export const MODERATION_CASE_UPDATE_MUTATION = gql` + mutation AdminModerationCaseUpdate($moderationCaseId: ID!, $expectedUpdatedAt: DateTime!, $operations: ReviewModerationCaseUpdateInput) { + reviewsMutation { moderationCaseUpdate(moderationCaseId: $moderationCaseId, expectedUpdatedAt: $expectedUpdatedAt, operations: $operations) { + moderationCase { id status updatedAt } operationResults { errors { ...CustomerContentUserErrorFields } } userErrors { ...CustomerContentUserErrorFields } + } } + } ${USER_ERRORS} +`; + +export const REVIEW_REQUESTS_QUERY = gql` + query AdminReviewRequests($first: Int, $after: String, $last: Int, $before: String, $where: ReviewRequestWhereInput, $orderBy: [ReviewRequestOrderByInput!]) { + reviewsQuery { reviewRequests(first: $first, after: $after, last: $last, before: $before, where: $where, orderBy: $orderBy) { totalCount edges { cursor node { + id orderId orderLineId channel status locale sourceChannel providerMessageId attemptCount scheduledAt sentAt deliveredAt openedAt submittedAt expiresAt lastError createdAt updatedAt + customer { id displayName email } product { id title } variant { id title } review { id title } + events(first: 100) { totalCount edges { node { id type providerEventId metadata occurredAt createdAt } } } + } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } + } +`; + +export const REVIEW_REQUEST_CREATE_MUTATION = gql` + mutation AdminReviewRequestCreate($input: ReviewRequestCreateInput!) { + reviewsMutation { reviewRequestCreate(input: $input) { reviewRequest { id updatedAt } userErrors { ...CustomerContentUserErrorFields } } } + } ${USER_ERRORS} +`; + +export const REVIEW_REQUEST_UPDATE_MUTATION = gql` + mutation AdminReviewRequestUpdate($reviewRequestId: ID!, $expectedUpdatedAt: DateTime!, $operations: ReviewRequestUpdateInput) { + reviewsMutation { reviewRequestUpdate(reviewRequestId: $reviewRequestId, expectedUpdatedAt: $expectedUpdatedAt, operations: $operations) { + reviewRequest { id status updatedAt } operationResults { errors { ...CustomerContentUserErrorFields } } userErrors { ...CustomerContentUserErrorFields } + } } + } ${USER_ERRORS} +`; + +export const EXTERNAL_REFERENCES_QUERY = gql` + query AdminExternalReferences($first: Int, $after: String, $last: Int, $before: String, $where: ReviewContentExternalReferenceWhereInput, $orderBy: [ReviewContentExternalReferenceOrderByInput!]) { + reviewsQuery { contentExternalReferences(first: $first, after: $after, last: $last, before: $before, where: $where, orderBy: $orderBy) { totalCount edges { cursor node { + id externalSystem externalType externalId externalUrl direction syncStatus etag contentChecksum lastSyncedAt lastError metadata createdAt updatedAt deletedAt + content { id __typename body status author { displayName } } + } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } + } +`; + +export const EXTERNAL_REFERENCE_CREATE_MUTATION = gql` + mutation AdminExternalReferenceCreate($input: ReviewContentExternalReferenceCreateInput!) { + reviewsMutation { contentExternalReferenceCreate(input: $input) { externalReference { id updatedAt } userErrors { ...CustomerContentUserErrorFields } } } + } ${USER_ERRORS} +`; + +export const EXTERNAL_REFERENCE_UPDATE_MUTATION = gql` + mutation AdminExternalReferenceUpdate($externalReferenceId: ID!, $expectedUpdatedAt: DateTime!, $operations: ReviewContentExternalReferenceUpdateInput) { + reviewsMutation { contentExternalReferenceUpdate(externalReferenceId: $externalReferenceId, expectedUpdatedAt: $expectedUpdatedAt, operations: $operations) { + externalReference { id updatedAt syncStatus } operationResults { errors { ...CustomerContentUserErrorFields } } userErrors { ...CustomerContentUserErrorFields } + } } + } ${USER_ERRORS} +`; + +export const EXTERNAL_REFERENCE_DELETE_MUTATION = gql` + mutation AdminExternalReferenceDelete($input: ReviewContentExternalReferenceDeleteInput!) { + reviewsMutation { contentExternalReferenceDelete(input: $input) { deletedExternalReferenceId userErrors { ...CustomerContentUserErrorFields } } } + } ${USER_ERRORS} +`; + +export const QUESTION_SUBSCRIPTION_UPDATE_MUTATION = gql` + mutation AdminQuestionSubscriptionUpdate($subscriptionId: ID!, $expectedUpdatedAt: DateTime!, $operations: ProductQuestionSubscriptionUpdateInput) { + reviewsMutation { productQuestionSubscriptionUpdate(subscriptionId: $subscriptionId, expectedUpdatedAt: $expectedUpdatedAt, operations: $operations) { + subscription { id status channel locale updatedAt } operationResults { errors { ...CustomerContentUserErrorFields } } userErrors { ...CustomerContentUserErrorFields } + } } + } ${USER_ERRORS} +`; diff --git a/admin/src/domains/customer-content/management/hooks.ts b/admin/src/domains/customer-content/management/hooks.ts new file mode 100644 index 000000000..cb39a4fea --- /dev/null +++ b/admin/src/domains/customer-content/management/hooks.ts @@ -0,0 +1,165 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation, useQuery } from "@apollo/client/react"; +import type { + ApiReviewContent, + ApiReviewContentConnection, + ApiReviewContentExternalReference, + ApiReviewContentExternalReferenceConnection, + ApiReviewContentExternalReferenceOrderByInput, + ApiReviewContentExternalReferenceWhereInput, + ApiProductQuestionSubscriptionUpdateInput, + ApiReviewContentExternalReferenceCreateInput, + ApiReviewContentExternalReferenceDeleteInput, + ApiReviewContentExternalReferenceUpdateInput, + ApiReviewContentReport, + ApiReviewContentReportConnection, + ApiReviewContentReportOrderByInput, + ApiReviewContentReportWhereInput, + ApiReviewContentReportUpdateInput, + ApiReviewContentOrderByInput, + ApiReviewContentWhereInput, + ApiReviewModerationCase, + ApiReviewModerationCaseConnection, + ApiReviewModerationCaseOrderByInput, + ApiReviewModerationCaseWhereInput, + ApiReviewModerationCaseCreateInput, + ApiReviewModerationCaseUpdateInput, + ApiReviewRatingCriterionCreateInput, + ApiReviewRatingCriterionDeleteInput, + ApiReviewRatingCriterionUpdateInput, + ApiReviewRequest, + ApiReviewRequestConnection, + ApiReviewRequestOrderByInput, + ApiReviewRequestWhereInput, + ApiReviewRequestCreateInput, + ApiReviewRequestUpdateInput, + ApiReviewStoreConfigurationUpdateInput, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { + CONTENT_REPORTS_QUERY, EXTERNAL_REFERENCES_QUERY, MODERATION_CASES_QUERY, MODERATION_CONTENTS_QUERY, + RATING_CRITERIA_QUERY, REVIEW_CONFIGURATION_QUERY, REVIEW_REQUESTS_QUERY, + CONTENT_REPORT_UPDATE_MUTATION, EXTERNAL_REFERENCE_CREATE_MUTATION, EXTERNAL_REFERENCE_DELETE_MUTATION, + EXTERNAL_REFERENCE_UPDATE_MUTATION, MODERATION_CASE_CREATE_MUTATION, MODERATION_CASE_UPDATE_MUTATION, + QUESTION_SUBSCRIPTION_UPDATE_MUTATION, RATING_CRITERION_CREATE_MUTATION, RATING_CRITERION_DELETE_MUTATION, + RATING_CRITERION_UPDATE_MUTATION, REVIEW_CONFIGURATION_UPDATE_MUTATION, REVIEW_REQUEST_CREATE_MUTATION, + REVIEW_REQUEST_UPDATE_MUTATION, +} from "./graphql"; +import type { ManagementUserError, RatingCriterion, ReviewConfiguration } from "./types"; + +type Connection = { totalCount: number; edges: Array<{ node: T }>; pageInfo?: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string | null; endCursor?: string | null } }; +type OperationPayload = { userErrors: ManagementUserError[]; operationResults?: Array<{ errors: ManagementUserError[] }>; } & T; +const errorsOf = (payload?: OperationPayload | null) => [...(payload?.userErrors ?? []), ...(payload?.operationResults?.flatMap((item) => item.errors) ?? [])]; + +export function useReviewConfiguration() { + return useQuery<{ reviewsQuery: { storeConfiguration: ReviewConfiguration | null } }>(REVIEW_CONFIGURATION_QUERY, { fetchPolicy: "cache-and-network" }); +} +export function useRatingCriteria() { + return useQuery<{ reviewsQuery: { ratingCriteria: Connection } }>(RATING_CRITERIA_QUERY, { fetchPolicy: "cache-and-network" }); +} +export interface ModerationContentsQueryVariables extends RelayCursorPaginationVariables { + where?: ApiReviewContentWhereInput | null; + orderBy?: ApiReviewContentOrderByInput[] | null; +} +export interface ContentReportsQueryVariables extends RelayCursorPaginationVariables { + where?: ApiReviewContentReportWhereInput | null; + orderBy?: ApiReviewContentReportOrderByInput[] | null; +} +export interface ModerationCasesQueryVariables extends RelayCursorPaginationVariables { + where?: ApiReviewModerationCaseWhereInput | null; + orderBy?: ApiReviewModerationCaseOrderByInput[] | null; +} +export interface ReviewRequestsQueryVariables extends RelayCursorPaginationVariables { + where?: ApiReviewRequestWhereInput | null; + orderBy?: ApiReviewRequestOrderByInput[] | null; +} +export interface ExternalReferencesQueryVariables extends RelayCursorPaginationVariables { + where?: ApiReviewContentExternalReferenceWhereInput | null; + orderBy?: ApiReviewContentExternalReferenceOrderByInput[] | null; +} + +export function useModerationContents(variables: ModerationContentsQueryVariables = { first: 20 }) { + const result = useRelayConnectionQuery< + { reviewsQuery: { contents: ApiReviewContentConnection } }, + ModerationContentsQueryVariables, + ApiReviewContent, + ApiReviewContentConnection + >({ query: MODERATION_CONTENTS_QUERY, variables, getConnection: (data) => data?.reviewsQuery.contents }); + return { contents: result.nodes, ...result }; +} +export function useContentReports(variables: ContentReportsQueryVariables = { first: 20 }) { + const result = useRelayConnectionQuery< + { reviewsQuery: { contentReports: ApiReviewContentReportConnection } }, + ContentReportsQueryVariables, + ApiReviewContentReport, + ApiReviewContentReportConnection + >({ query: CONTENT_REPORTS_QUERY, variables, getConnection: (data) => data?.reviewsQuery.contentReports }); + return { reports: result.nodes, ...result }; +} +export function useModerationCases(variables: ModerationCasesQueryVariables = { first: 20 }) { + const result = useRelayConnectionQuery< + { reviewsQuery: { moderationCases: ApiReviewModerationCaseConnection } }, + ModerationCasesQueryVariables, + ApiReviewModerationCase, + ApiReviewModerationCaseConnection + >({ query: MODERATION_CASES_QUERY, variables, getConnection: (data) => data?.reviewsQuery.moderationCases }); + return { moderationCases: result.nodes, ...result }; +} +export function useReviewRequests(variables: ReviewRequestsQueryVariables = { first: 20 }) { + const result = useRelayConnectionQuery< + { reviewsQuery: { reviewRequests: ApiReviewRequestConnection } }, + ReviewRequestsQueryVariables, + ApiReviewRequest, + ApiReviewRequestConnection + >({ query: REVIEW_REQUESTS_QUERY, variables, getConnection: (data) => data?.reviewsQuery.reviewRequests }); + return { reviewRequests: result.nodes, ...result }; +} +export function useExternalReferences(variables: ExternalReferencesQueryVariables = { first: 20 }) { + const result = useRelayConnectionQuery< + { reviewsQuery: { contentExternalReferences: ApiReviewContentExternalReferenceConnection } }, + ExternalReferencesQueryVariables, + ApiReviewContentExternalReference, + ApiReviewContentExternalReferenceConnection + >({ query: EXTERNAL_REFERENCES_QUERY, variables, getConnection: (data) => data?.reviewsQuery.contentExternalReferences }); + return { externalReferences: result.nodes, ...result }; +} +export function useManagementMutations() { + const [configurationUpdate, configurationState] = useMutation<{ reviewsMutation: { storeConfigurationUpdate: OperationPayload<{ configuration: { id: string; revision: number; updatedAt: string } | null }> } }, { configurationId: string; expectedRevision: number; operations: ApiReviewStoreConfigurationUpdateInput }>(REVIEW_CONFIGURATION_UPDATE_MUTATION); + const [criterionCreate, criterionCreateState] = useMutation<{ reviewsMutation: { ratingCriterionCreate: OperationPayload<{ criterion: { id: string; updatedAt: string } | null }> } }, { input: ApiReviewRatingCriterionCreateInput }>(RATING_CRITERION_CREATE_MUTATION); + const [criterionUpdate, criterionUpdateState] = useMutation<{ reviewsMutation: { ratingCriterionUpdate: OperationPayload<{ criterion: { id: string; updatedAt: string } | null }> } }, { criterionId: string; expectedUpdatedAt: string; operations: ApiReviewRatingCriterionUpdateInput }>(RATING_CRITERION_UPDATE_MUTATION); + const [criterionDelete, criterionDeleteState] = useMutation<{ reviewsMutation: { ratingCriterionDelete: OperationPayload<{ deletedCriterionId: string | null }> } }, { input: ApiReviewRatingCriterionDeleteInput }>(RATING_CRITERION_DELETE_MUTATION); + const [reportUpdate, reportState] = useMutation<{ reviewsMutation: { contentReportUpdate: OperationPayload<{ contentReport: { id: string; updatedAt: string } | null }> } }, { contentReportId: string; expectedUpdatedAt: string; operations: ApiReviewContentReportUpdateInput }>(CONTENT_REPORT_UPDATE_MUTATION); + const [caseCreate, caseCreateState] = useMutation<{ reviewsMutation: { moderationCaseCreate: OperationPayload<{ moderationCase: { id: string; updatedAt: string } | null }> } }, { input: ApiReviewModerationCaseCreateInput }>(MODERATION_CASE_CREATE_MUTATION); + const [caseUpdate, caseUpdateState] = useMutation<{ reviewsMutation: { moderationCaseUpdate: OperationPayload<{ moderationCase: { id: string; updatedAt: string } | null }> } }, { moderationCaseId: string; expectedUpdatedAt: string; operations: ApiReviewModerationCaseUpdateInput }>(MODERATION_CASE_UPDATE_MUTATION); + const [requestCreate, requestCreateState] = useMutation<{ reviewsMutation: { reviewRequestCreate: OperationPayload<{ reviewRequest: { id: string; updatedAt: string } | null }> } }, { input: ApiReviewRequestCreateInput }>(REVIEW_REQUEST_CREATE_MUTATION); + const [requestUpdate, requestUpdateState] = useMutation<{ reviewsMutation: { reviewRequestUpdate: OperationPayload<{ reviewRequest: { id: string; updatedAt: string } | null }> } }, { reviewRequestId: string; expectedUpdatedAt: string; operations: ApiReviewRequestUpdateInput }>(REVIEW_REQUEST_UPDATE_MUTATION); + const [externalCreate, externalCreateState] = useMutation<{ reviewsMutation: { contentExternalReferenceCreate: OperationPayload<{ externalReference: { id: string; updatedAt: string } | null }> } }, { input: ApiReviewContentExternalReferenceCreateInput }>(EXTERNAL_REFERENCE_CREATE_MUTATION); + const [externalUpdate, externalUpdateState] = useMutation<{ reviewsMutation: { contentExternalReferenceUpdate: OperationPayload<{ externalReference: { id: string; updatedAt: string } | null }> } }, { externalReferenceId: string; expectedUpdatedAt: string; operations: ApiReviewContentExternalReferenceUpdateInput }>(EXTERNAL_REFERENCE_UPDATE_MUTATION); + const [externalDelete, externalDeleteState] = useMutation<{ reviewsMutation: { contentExternalReferenceDelete: OperationPayload<{ deletedExternalReferenceId: string | null }> } }, { input: ApiReviewContentExternalReferenceDeleteInput }>(EXTERNAL_REFERENCE_DELETE_MUTATION); + const [subscriptionUpdate, subscriptionState] = useMutation<{ reviewsMutation: { productQuestionSubscriptionUpdate: OperationPayload<{ subscription: { id: string; updatedAt: string } | null }> } }, { subscriptionId: string; expectedUpdatedAt: string; operations: ApiProductQuestionSubscriptionUpdateInput }>(QUESTION_SUBSCRIPTION_UPDATE_MUTATION); + + const run = useCallback(async (promise: Promise<{ data?: { reviewsMutation: T } | null }>, key: keyof T) => { + const result = await promise; const payload = result.data?.reviewsMutation[key] as OperationPayload> | undefined; + return { payload: payload ?? null, errors: errorsOf(payload) }; + }, []); + + return { + updateConfiguration: (configurationId: string, expectedRevision: number, operations: ApiReviewStoreConfigurationUpdateInput) => run(configurationUpdate({ variables: { configurationId, expectedRevision, operations } }), "storeConfigurationUpdate"), + createCriterion: (input: ApiReviewRatingCriterionCreateInput) => run(criterionCreate({ variables: { input } }), "ratingCriterionCreate"), + updateCriterion: (criterionId: string, expectedUpdatedAt: string, operations: ApiReviewRatingCriterionUpdateInput) => run(criterionUpdate({ variables: { criterionId, expectedUpdatedAt, operations } }), "ratingCriterionUpdate"), + deleteCriterion: (input: ApiReviewRatingCriterionDeleteInput) => run(criterionDelete({ variables: { input } }), "ratingCriterionDelete"), + updateReport: (contentReportId: string, expectedUpdatedAt: string, operations: ApiReviewContentReportUpdateInput) => run(reportUpdate({ variables: { contentReportId, expectedUpdatedAt, operations } }), "contentReportUpdate"), + createCase: (input: ApiReviewModerationCaseCreateInput) => run(caseCreate({ variables: { input } }), "moderationCaseCreate"), + updateCase: (moderationCaseId: string, expectedUpdatedAt: string, operations: ApiReviewModerationCaseUpdateInput) => run(caseUpdate({ variables: { moderationCaseId, expectedUpdatedAt, operations } }), "moderationCaseUpdate"), + createRequest: (input: ApiReviewRequestCreateInput) => run(requestCreate({ variables: { input } }), "reviewRequestCreate"), + updateRequest: (reviewRequestId: string, expectedUpdatedAt: string, operations: ApiReviewRequestUpdateInput) => run(requestUpdate({ variables: { reviewRequestId, expectedUpdatedAt, operations } }), "reviewRequestUpdate"), + createExternalReference: (input: ApiReviewContentExternalReferenceCreateInput) => run(externalCreate({ variables: { input } }), "contentExternalReferenceCreate"), + updateExternalReference: (externalReferenceId: string, expectedUpdatedAt: string, operations: ApiReviewContentExternalReferenceUpdateInput) => run(externalUpdate({ variables: { externalReferenceId, expectedUpdatedAt, operations } }), "contentExternalReferenceUpdate"), + deleteExternalReference: (input: ApiReviewContentExternalReferenceDeleteInput) => run(externalDelete({ variables: { input } }), "contentExternalReferenceDelete"), + updateSubscription: (subscriptionId: string, expectedUpdatedAt: string, operations: ApiProductQuestionSubscriptionUpdateInput) => run(subscriptionUpdate({ variables: { subscriptionId, expectedUpdatedAt, operations } }), "productQuestionSubscriptionUpdate"), + loading: [configurationState, criterionCreateState, criterionUpdateState, criterionDeleteState, reportState, caseCreateState, caseUpdateState, requestCreateState, requestUpdateState, externalCreateState, externalUpdateState, externalDeleteState, subscriptionState].some((item) => item.loading), + }; +} diff --git a/admin/src/domains/customer-content/management/insights/index.ts b/admin/src/domains/customer-content/management/insights/index.ts new file mode 100644 index 000000000..0e3676a2b --- /dev/null +++ b/admin/src/domains/customer-content/management/insights/index.ts @@ -0,0 +1 @@ +export { ProductInsightsModal } from "./product-insights-modal"; diff --git a/admin/src/domains/customer-content/management/insights/product-insights-modal.tsx b/admin/src/domains/customer-content/management/insights/product-insights-modal.tsx new file mode 100644 index 000000000..cb7390fac --- /dev/null +++ b/admin/src/domains/customer-content/management/insights/product-insights-modal.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Alert, Descriptions, Flex, Progress, Skeleton, Statistic, Typography } from "antd"; +import { LuChartBar as BarChartOutlined, LuCircleHelp as QuestionOutlined, LuStar as StarFilled } from "react-icons/lu"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useProductReviewsWidget } from "@/domains/customer-content/reviews/hooks"; +import type { ProductInsightsModalPayload } from "../modals"; + +export function ProductInsightsModal() { + const { payload, pop } = useModalStackContext(); + const value = payload as ProductInsightsModalPayload; + const widget = useProductReviewsWidget(value.product.id); + const summary = widget.data?.reviewSummary; + const questions = widget.data?.questionSummary; + const reviewCount = summary?.reviewCount ?? 0; + const averageRating = summary?.averageRating ?? 0; + const verifiedReviewCount = summary?.verifiedReviewCount ?? 0; + const mediaReviewCount = summary?.mediaReviewCount ?? 0; + const ratingBreakdown: Record = summary ? { + 5: summary.ratingBreakdown.rating5Count, + 4: summary.ratingBreakdown.rating4Count, + 3: summary.ratingBreakdown.rating3Count, + 2: summary.ratingBreakdown.rating2Count, + 1: summary.ratingBreakdown.rating1Count, + } : { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }; + const criteria = summary?.criteria.length ? summary.criteria.map((item) => ({ + id: item.criterion.id, + title: item.criterion.defaultTitle, + averageRating: item.averageRating, + reviewCount: item.reviewCount, + })) : []; + + return }> + + } /> + + + {widget.error ? : null} + + } /> + + + + + + + + {[5, 4, 3, 2, 1].map((rating) => + {rating} stars + String(ratingBreakdown[rating])} /> + )} + + + + + ({ + key: criterion.id, + label: criterion.title, + children: `${criterion.averageRating.toFixed(2)} / 5 from ${criterion.reviewCount} reviews`, + }))} /> + + + } /> + {widget.loading && !widget.data ? : + + + + + + } + + ; +} diff --git a/admin/src/domains/customer-content/management/modals.ts b/admin/src/domains/customer-content/management/modals.ts new file mode 100644 index 000000000..ceadecda2 --- /dev/null +++ b/admin/src/domains/customer-content/management/modals.ts @@ -0,0 +1,39 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; +import type { RatingCriterion } from "./types"; +import type { ApiReviewContentExternalReference, ApiReviewModerationCase, ApiReviewRequest } from "@/graphql/types"; + +export const RATING_CRITERION_MODAL_TYPE = "review-rating-criterion"; +export const MODERATION_CASE_MODAL_TYPE = "review-moderation-case"; +export const REVIEW_REQUEST_MODAL_TYPE = "review-request"; +export const EXTERNAL_REFERENCE_MODAL_TYPE = "review-external-reference"; +export const PRODUCT_INSIGHTS_MODAL_TYPE = "product-insights"; + +interface SavedPayload extends IModalStackPayload { onSaved?: () => Promise | unknown } +export interface RatingCriterionModalPayload extends SavedPayload { criterion?: RatingCriterion } +export interface ModerationCaseModalPayload extends SavedPayload { moderationCase?: ApiReviewModerationCase; contentId?: string } +export interface ReviewRequestModalPayload extends SavedPayload { reviewRequest?: ApiReviewRequest } +export interface ExternalReferenceModalPayload extends SavedPayload { + externalReference?: ApiReviewContentExternalReference; + contentId?: string; + contentLabel?: string; +} +export interface ProductInsightsModalPayload extends IModalStackPayload { + product: { id: string; title: string; handle: string }; +} + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [RATING_CRITERION_MODAL_TYPE]: RatingCriterionModalPayload; + [MODERATION_CASE_MODAL_TYPE]: ModerationCaseModalPayload; + [REVIEW_REQUEST_MODAL_TYPE]: ReviewRequestModalPayload; + [EXTERNAL_REFERENCE_MODAL_TYPE]: ExternalReferenceModalPayload; + [PRODUCT_INSIGHTS_MODAL_TYPE]: ProductInsightsModalPayload; + } +} + +export const useRatingCriterionModal = createModalStackHook(RATING_CRITERION_MODAL_TYPE); +export const useModerationCaseModal = createModalStackHook(MODERATION_CASE_MODAL_TYPE); +export const useReviewRequestModal = createModalStackHook(REVIEW_REQUEST_MODAL_TYPE); +export const useExternalReferenceModal = createModalStackHook(EXTERNAL_REFERENCE_MODAL_TYPE); +export const useProductInsightsModal = createModalStackHook(PRODUCT_INSIGHTS_MODAL_TYPE); diff --git a/admin/src/domains/customer-content/management/moderation/filter-schema.ts b/admin/src/domains/customer-content/management/moderation/filter-schema.ts new file mode 100644 index 000000000..2101e8f81 --- /dev/null +++ b/admin/src/domains/customer-content/management/moderation/filter-schema.ts @@ -0,0 +1,16 @@ +import { FilterType, dateOperators, enumOperators, numberOperators } from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { ReviewContentAuthorType, ReviewContentKind, ReviewContentStatus } from "@/graphql/types"; + +const enumOptions = (values: T[]) => values.map((value) => ({ + label: value.toLowerCase().replaceAll("_", " "), + value, +})); + +export const filterSchema: IFilterSchema[] = [ + { key: "status", label: "Status", description: "Filter by moderation status", type: FilterType.Enum, operators: enumOperators, payloadKey: "status", options: enumOptions(Object.values(ReviewContentStatus)) }, + { key: "kind", label: "Content type", description: "Filter by review, question, answer, or reply", type: FilterType.Enum, operators: enumOperators, payloadKey: "kind", options: enumOptions(Object.values(ReviewContentKind)) }, + { key: "authorType", label: "Author type", description: "Filter by content author type", type: FilterType.Enum, operators: enumOperators, payloadKey: "authorType", options: enumOptions(Object.values(ReviewContentAuthorType)) }, + { key: "reportCount", label: "Reports", description: "Filter by abuse report count", type: FilterType.Number, operators: numberOperators, payloadKey: "reportCount" }, + { key: "updatedAt", label: "Updated date", description: "Filter by last update date", type: FilterType.DateRange, operators: dateOperators, payloadKey: "updatedAt" }, +]; diff --git a/admin/src/domains/customer-content/management/moderation/page-config.ts b/admin/src/domains/customer-content/management/moderation/page-config.ts new file mode 100644 index 000000000..1b6b8a13c --- /dev/null +++ b/admin/src/domains/customer-content/management/moderation/page-config.ts @@ -0,0 +1,28 @@ +import type { SortFieldMapping, UsePageConfigReturn } from "@/hooks"; +import type { ApiReviewContentOrderByInput, ApiReviewContentWhereInput } from "@/graphql/types"; +import { ReviewContentOrderField } from "@/graphql/types"; +import type { ModerationContentsQueryVariables } from "../hooks"; + +export const moderationSortFieldMapping: SortFieldMapping = { + title: ReviewContentOrderField.Title, + kind: ReviewContentOrderField.Kind, + authorDisplayName: ReviewContentOrderField.AuthorDisplayName, + locale: ReviewContentOrderField.Locale, + status: ReviewContentOrderField.Status, + reportCount: ReviewContentOrderField.ReportCount, + updatedAt: ReviewContentOrderField.UpdatedAt, +}; + +export const buildModerationSearchCondition = (search: string): Partial => ({ + _or: [ + { title: { _containsi: search } }, + { body: { _containsi: search } }, + { authorDisplayName: { _containsi: search } }, + ], +}); + +export function buildModerationQueryVariables( + pageConfig: Pick, "first" | "after" | "last" | "before" | "where" | "orderBy">, +): ModerationContentsQueryVariables { + return { ...pageConfig, where: pageConfig.where ?? null, orderBy: pageConfig.orderBy as ApiReviewContentOrderByInput[] | undefined }; +} diff --git a/admin/src/domains/customer-content/management/moderation/page.tsx b/admin/src/domains/customer-content/management/moderation/page.tsx new file mode 100644 index 000000000..451628cb1 --- /dev/null +++ b/admin/src/domains/customer-content/management/moderation/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, App, Button, Flex, Tag, Typography } from "antd"; +import { LuFolderPlus as FolderAddOutlined, LuBan as StopOutlined } from "react-icons/lu"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { AllCommunityModule, GridStateModule, ModuleRegistry, type ColDef } from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { ApiReviewContent, ApiReviewContentWhereInput } from "@/graphql/types"; +import { ReviewContentKind, ReviewContentOrderField, ReviewContentStatus } from "@/graphql/types"; +import { useReviewModal } from "@/domains/customer-content/reviews/modals"; +import { useQuestionModal } from "@/domains/customer-content/questions/modals"; +import { useContentActions } from "@/domains/customer-content/shared/hooks"; +import { useUgcNavigation } from "@/domains/customer-content/use-ugc-navigation"; +import { useModerationContents } from "../hooks"; +import { useModerationCaseModal } from "../modals"; +import { filterSchema } from "./filter-schema"; +import { buildModerationQueryVariables, buildModerationSearchCondition, moderationSortFieldMapping } from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); + +const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }); + +function ContentCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return {data.title || data.kind.toLowerCase().replaceAll("_", " ")}{data.body}; +} + +export default function ModerationQueuePage() { + const { backToUgc } = useUgcNavigation(); + const { message, modal } = App.useApp(); + const theme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig({ + gridRef, storageKey: "moderation-grid-state", filterSchema, sortFieldMapping: moderationSortFieldMapping, + defaultSort: [{ colId: "updatedAt", sort: "desc" }], defaultPageSize: 10, pageSizeOptions: [10, 20, 50], buildSearchCondition: buildModerationSearchCondition, + }); + const variables = useMemo(() => buildModerationQueryVariables(pageConfig), [pageConfig.first, pageConfig.after, pageConfig.last, pageConfig.before, pageConfig.where, pageConfig.orderBy]); + const { contents, totalCount, pageInfo, loading, error, refetch } = useModerationContents(variables); + const actions = useContentActions(); + const reviewModal = useReviewModal(); + const questionModal = useQuestionModal(); + const caseModal = useModerationCaseModal(); + + const open = useCallback((content: ApiReviewContent) => { + if (content.kind === ReviewContentKind.Review) reviewModal.push({ entityId: content.id, onSaved: refetch }); + if (content.kind === ReviewContentKind.ProductQuestion) questionModal.push({ entityId: content.id, onSaved: refetch }); + }, [questionModal, refetch, reviewModal]); + const redact = useCallback(async (content: ApiReviewContent) => { + const confirmed = await modal.confirm({ title: "Redact content?", content: "Personal content will be irreversibly redacted.", okText: "Redact", okButtonProps: { danger: true } }); + if (!confirmed) return; + const result = await actions.redact(content.id, content.revision); + if (result.errors.length) return message.error(result.errors.map((item) => item.message).join(" ")); + await refetch(); message.success("Content redacted"); + }, [actions, message, modal, refetch]); + const next = useCallback(() => { if (pageInfo?.endCursor) pageConfig.goToNextPage(pageInfo.endCursor); }, [pageConfig, pageInfo?.endCursor]); + const prev = useCallback(() => { if (pageInfo?.startCursor) pageConfig.goToPrevPage(pageInfo.startCursor); }, [pageConfig, pageInfo?.startCursor]); + + const columnDefs = useMemo[]>(() => [ + { headerName: "Content", colId: "title", cellRenderer: ContentCell, minWidth: 340, flex: 2 }, + { headerName: "Type", field: "kind", width: 150, valueFormatter: ({ value }) => String(value).toLowerCase().replaceAll("_", " ") }, + { headerName: "Author", colId: "authorDisplayName", minWidth: 180, valueGetter: ({ data }) => data?.author.displayName }, + { headerName: "Locale", field: "locale", width: 105 }, + { headerName: "Status", field: "status", width: 125, cellRenderer: ({ value }: CustomCellRendererProps) => {String(value).toLowerCase()} }, + { headerName: "Reports", colId: "reportCount", width: 105, valueGetter: ({ data }) => data?.metrics.reportCount ?? 0 }, + { headerName: "Updated", field: "updatedAt", minWidth: 180, cellRenderer: ({ value }: CustomCellRendererProps) => {value ? dateFormatter.format(new Date(value)) : ""} }, + { headerName: "Actions", colId: "actions", width: 190, sortable: false, cellRenderer: ({ data }: CustomCellRendererProps) => data ? : null }, + ], [caseModal, redact, refetch]); + const defaultColDef = useMemo>(() => ({ resizable: true, sortable: true, comparator: () => 0, cellStyle: { display: "flex", alignItems: "center" } }), []); + + return + } /> +
    + {error || actions.error ? : null} +
    ref={gridRef} theme={theme} rowData={contents} loading={loading || actions.loading} columnDefs={columnDefs} defaultColDef={defaultColDef} getRowId={({ data }) => data.id} rowHeight={68} suppressCellFocus suppressMovableColumns rowStyle={{ cursor: "pointer" }} onRowClicked={({ data }) => data && open(data)} onSortChanged={pageConfig.onSortChanged} initialState={pageConfig.gridStateProps.initialState} onStateUpdated={pageConfig.gridStateProps.onStateUpdated} />
    + +
    +
    ; +} diff --git a/admin/src/domains/customer-content/management/reports/filter-schema.ts b/admin/src/domains/customer-content/management/reports/filter-schema.ts new file mode 100644 index 000000000..bd3c95b10 --- /dev/null +++ b/admin/src/domains/customer-content/management/reports/filter-schema.ts @@ -0,0 +1,13 @@ +import { FilterType, dateOperators, enumOperators, stringOperators } from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { ReviewContentReportReason, ReviewContentReportStatus } from "@/graphql/types"; + +const enumOptions = (values: T[]) => values.map((value) => ({ label: value.toLowerCase().replaceAll("_", " "), value })); + +export const filterSchema: IFilterSchema[] = [ + { key: "status", label: "Status", description: "Filter by report workflow status", type: FilterType.Enum, operators: enumOperators, payloadKey: "status", options: enumOptions(Object.values(ReviewContentReportStatus)) }, + { key: "reason", label: "Reason", description: "Filter by reported reason", type: FilterType.Enum, operators: enumOperators, payloadKey: "reason", options: enumOptions(Object.values(ReviewContentReportReason)) }, + { key: "assignee", label: "Assigned principal", description: "Filter by assigned principal ID", type: FilterType.String, operators: stringOperators, payloadKey: "assignedToPrincipalId" }, + { key: "createdAt", label: "Created date", description: "Filter by report creation date", type: FilterType.DateRange, operators: dateOperators, payloadKey: "createdAt" }, + { key: "resolvedAt", label: "Resolved date", description: "Filter by report resolution date", type: FilterType.DateRange, operators: dateOperators, payloadKey: "resolvedAt" }, +]; diff --git a/admin/src/domains/customer-content/management/reports/page-config.ts b/admin/src/domains/customer-content/management/reports/page-config.ts new file mode 100644 index 000000000..1cdac5539 --- /dev/null +++ b/admin/src/domains/customer-content/management/reports/page-config.ts @@ -0,0 +1,26 @@ +import type { SortFieldMapping, UsePageConfigReturn } from "@/hooks"; +import type { ApiReviewContentReportOrderByInput, ApiReviewContentReportWhereInput } from "@/graphql/types"; +import { ReviewContentReportOrderField } from "@/graphql/types"; +import type { ContentReportsQueryVariables } from "../hooks"; + +export const reportSortFieldMapping: SortFieldMapping = { + reason: ReviewContentReportOrderField.Reason, + reporterCustomerId: ReviewContentReportOrderField.ReporterCustomerId, + status: ReviewContentReportOrderField.Status, + assignedToPrincipalId: ReviewContentReportOrderField.AssignedToPrincipalId, + resolvedAt: ReviewContentReportOrderField.ResolvedAt, + createdAt: ReviewContentReportOrderField.CreatedAt, +}; + +export const buildReportSearchCondition = (search: string): Partial => ({ + _or: [ + { assignedToPrincipalId: { _containsi: search } }, + { resolvedByPrincipalId: { _containsi: search } }, + ], +}); + +export function buildReportsQueryVariables( + pageConfig: Pick, "first" | "after" | "last" | "before" | "where" | "orderBy">, +): ContentReportsQueryVariables { + return { ...pageConfig, where: pageConfig.where ?? null, orderBy: pageConfig.orderBy as ApiReviewContentReportOrderByInput[] | undefined }; +} diff --git a/admin/src/domains/customer-content/management/reports/page.tsx b/admin/src/domains/customer-content/management/reports/page.tsx new file mode 100644 index 000000000..66c3d68bd --- /dev/null +++ b/admin/src/domains/customer-content/management/reports/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { Alert, App, Button, Flex, Input, Modal, Select, Tag, Typography } from "antd"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { AllCommunityModule, GridStateModule, ModuleRegistry, type ColDef } from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { ApiReviewContentReport, ApiReviewContentReportWhereInput } from "@/graphql/types"; +import { ReviewContentReportOrderField, ReviewContentReportStatus } from "@/graphql/types"; +import { useUgcNavigation } from "@/domains/customer-content/use-ugc-navigation"; +import { useContentReports, useManagementMutations } from "../hooks"; +import { filterSchema } from "./filter-schema"; +import { buildReportSearchCondition, buildReportsQueryVariables, reportSortFieldMapping } from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); + +const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }); +function ContentCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return {data.content.body}{data.content.author.displayName}; +} + +export default function ContentReportsPage() { + const { backToUgc } = useUgcNavigation(); + const { message } = App.useApp(); + const theme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig({ + gridRef, storageKey: "content-reports-grid-state", filterSchema, sortFieldMapping: reportSortFieldMapping, + defaultSort: [{ colId: "createdAt", sort: "desc" }], defaultPageSize: 10, pageSizeOptions: [10, 20, 50], buildSearchCondition: buildReportSearchCondition, + }); + const variables = useMemo(() => buildReportsQueryVariables(pageConfig), [pageConfig.first, pageConfig.after, pageConfig.last, pageConfig.before, pageConfig.where, pageConfig.orderBy]); + const { reports, totalCount, pageInfo, loading, error, refetch } = useContentReports(variables); + const mutations = useManagementMutations(); + const [selected, setSelected] = useState(null); + const [assignee, setAssignee] = useState(""); + const [status, setStatus] = useState(); + const [note, setNote] = useState(""); + const openReport = useCallback((report: ApiReviewContentReport) => { setSelected(report); setAssignee(report.assignedToPrincipalId ?? ""); setStatus([ReviewContentReportStatus.Actioned, ReviewContentReportStatus.Dismissed].includes(report.status) ? report.status : undefined); setNote(report.resolutionNote ?? ""); }, []); + const save = useCallback(async () => { + if (!selected) return; + const result = await mutations.updateReport(selected.id, selected.updatedAt, { assignment: { assignedToPrincipalId: assignee.trim() || null }, resolution: status ? { status, note: note.trim() || null } : undefined }); + if (result.errors.length) return message.error(result.errors.map((item) => item.message).join(" ")); + await refetch(); setSelected(null); message.success("Report updated"); + }, [assignee, message, mutations, note, refetch, selected, status]); + const next = useCallback(() => { if (pageInfo?.endCursor) pageConfig.goToNextPage(pageInfo.endCursor); }, [pageConfig, pageInfo?.endCursor]); + const prev = useCallback(() => { if (pageInfo?.startCursor) pageConfig.goToPrevPage(pageInfo.startCursor); }, [pageConfig, pageInfo?.startCursor]); + const columnDefs = useMemo[]>(() => [ + { headerName: "Reported content", colId: "content", cellRenderer: ContentCell, minWidth: 360, flex: 2, sortable: false }, + { headerName: "Reason", field: "reason", minWidth: 180, valueFormatter: ({ value }) => String(value).toLowerCase().replaceAll("_", " ") }, + { headerName: "Reporter", colId: "reporterCustomerId", minWidth: 180, valueGetter: ({ data }) => data?.reporterCustomer?.displayName ?? "Anonymous" }, + { headerName: "Status", field: "status", width: 130, cellRenderer: ({ value }: CustomCellRendererProps) => {String(value).toLowerCase().replaceAll("_", " ")} }, + { headerName: "Assignee", field: "assignedToPrincipalId", minWidth: 180, valueFormatter: ({ value }) => value ?? "Unassigned" }, + { headerName: "Created", field: "createdAt", minWidth: 180, cellRenderer: ({ value }: CustomCellRendererProps) => {value ? dateFormatter.format(new Date(value)) : ""} }, + ], []); + const defaultColDef = useMemo>(() => ({ resizable: true, sortable: true, comparator: () => 0, cellStyle: { display: "flex", alignItems: "center" } }), []); + + return + } /> +
    + {error ? : null} +
    ref={gridRef} theme={theme} rowData={reports} loading={loading} columnDefs={columnDefs} defaultColDef={defaultColDef} getRowId={({ data }) => data.id} rowHeight={68} suppressCellFocus suppressMovableColumns rowStyle={{ cursor: "pointer" }} onRowClicked={({ data }) => data && openReport(data)} onSortChanged={pageConfig.onSortChanged} initialState={pageConfig.gridStateProps.initialState} onStateUpdated={pageConfig.gridStateProps.onStateUpdated} />
    + +
    + setSelected(null)} footer={}> +
    Assigned principal setAssignee(event.target.value)} style={{ marginTop: 8 }} />
    Resolution
    +
    Product *
    +
    Variant
    +
    Order ID * { setOrderId(event.target.value); change(); }} style={{ marginTop: 8 }} />
    Order line ID * { setOrderLineId(event.target.value); change(); }} style={{ marginTop: 8 }} />
    +
    ({ value: item.value, label: item.name }))} onChange={(next) => { setLocale(next); change(); }} showSearch />
    Scheduled at * { setScheduledAt(event.target.value); change(); }} style={{ marginTop: 8 }} />
    Expires at { setExpiresAt(event.target.value); change(); }} style={{ marginTop: 8 }} />
    + {current ? <> set("reviewModerationMode", value)} style={{ width: 240 }} /> + Questions set("answerModerationMode", value)} style={{ width: 240 }} /> + Duplicate review policy ({ value: status, label: status.toLowerCase() }))} onChange={(status) => void onUpdateSubscription(item.id, item.updatedAt, status)} /> : {item.status.toLowerCase()}}} /> : } + + ; +} diff --git a/admin/src/domains/customer-content/questions/graphql/details-fragment.ts b/admin/src/domains/customer-content/questions/graphql/details-fragment.ts new file mode 100644 index 000000000..645c98c44 --- /dev/null +++ b/admin/src/domains/customer-content/questions/graphql/details-fragment.ts @@ -0,0 +1,54 @@ +import { gql } from "@apollo/client"; + +export const QUESTION_DETAILS_FRAGMENT = gql` + fragment QuestionDetailsFields on ProductQuestion { + id revision title body locale status answerState + sourceChannel sourceMetadata idempotencyKey + moderationNote moderatedByPrincipalId moderatedAt + createdAt + author { + type principalId displayName email + customer { id displayName email preferredLocale } + } + product { id title } + variant { id title } + metrics { + likeCount dislikeCount reportCount openReportCount childCount officialChildCount + acceptedChildCount lastChildAt updatedAt + } + answers(first: 100, orderBy: [{ field: sortIndex, direction: asc }]) { + edges { node { + id revision body locale status moderationNote moderatedByPrincipalId moderatedAt + isOfficial isAccepted sortIndex createdAt updatedAt + author { type principalId displayName email customer { id displayName email } } + metrics { likeCount dislikeCount reportCount openReportCount } + } } + totalCount + } + subscriptions(first: 100) { + edges { node { + id channel status locale lastNotifiedAt createdAt updatedAt + subscriberCustomer { id displayName email } + } } + totalCount + } + votes(first: 100) { + edges { node { id type createdAt updatedAt voterCustomer { id displayName email } } } + totalCount + } + reports(first: 100) { + edges { node { + id reason details status assignedToPrincipalId resolutionNote resolvedByPrincipalId resolvedAt createdAt updatedAt + reporterCustomer { id displayName email } + } } + totalCount + } + externalReferences(first: 100) { + edges { node { + id externalSystem externalType externalId externalUrl direction syncStatus etag contentChecksum + lastSyncedAt lastError metadata createdAt updatedAt deletedAt + } } + totalCount + } + } +`; diff --git a/admin/src/domains/customer-content/questions/graphql/fragments.ts b/admin/src/domains/customer-content/questions/graphql/fragments.ts new file mode 100644 index 000000000..17a565343 --- /dev/null +++ b/admin/src/domains/customer-content/questions/graphql/fragments.ts @@ -0,0 +1,101 @@ +import { gql } from "@apollo/client"; + +export const QUESTION_LIST_FRAGMENT = gql` + fragment QuestionListFields on ProductQuestion { + id + body + status + answerState + createdAt + updatedAt + product { + id + title + } + author { + type + displayName + email + customer { + id + } + } + metrics { + childCount + officialChildCount + acceptedChildCount + likeCount + dislikeCount + reportCount + openReportCount + } + } +`; + +export const QUESTION_EDITOR_FRAGMENT = gql` + fragment QuestionEditorFields on ProductQuestion { + ...QuestionListFields + revision + locale + moderationNote + moderatedAt + author { + type + displayName + email + customer { + id + displayName + email + preferredLocale + } + } + answers(first: 50) { + edges { + node { + id + revision + body + locale + status + isOfficial + isAccepted + sortIndex + createdAt + updatedAt + author { + type + displayName + email + customer { + id + } + } + metrics { + likeCount + dislikeCount + } + } + } + totalCount + } + reports(first: 50) { + edges { + node { + id + reason + details + status + createdAt + reporterCustomer { + id + displayName + email + } + } + } + totalCount + } + } + ${QUESTION_LIST_FRAGMENT} +`; diff --git a/admin/src/domains/customer-content/questions/graphql/index.ts b/admin/src/domains/customer-content/questions/graphql/index.ts new file mode 100644 index 000000000..a84c23674 --- /dev/null +++ b/admin/src/domains/customer-content/questions/graphql/index.ts @@ -0,0 +1,4 @@ +export * from "./fragments"; +export * from "./details-fragment"; +export * from "./queries"; +export * from "./mutations"; diff --git a/admin/src/domains/customer-content/questions/graphql/mutations.ts b/admin/src/domains/customer-content/questions/graphql/mutations.ts new file mode 100644 index 000000000..920c63b93 --- /dev/null +++ b/admin/src/domains/customer-content/questions/graphql/mutations.ts @@ -0,0 +1,53 @@ +import { gql } from "@apollo/client"; +import { QUESTION_EDITOR_FRAGMENT } from "./fragments"; + +export const QUESTION_CREATE_MUTATION = gql` + mutation QuestionCreate($input: ProductQuestionCreateInput!) { + reviewsMutation { + productQuestionCreate(input: $input) { + productQuestion { + ...QuestionEditorFields + } + userErrors { + code + field + message + } + } + } + } + ${QUESTION_EDITOR_FRAGMENT} +`; + +export const QUESTION_UPDATE_MUTATION = gql` + mutation QuestionUpdate( + $productQuestionId: ID! + $expectedRevision: Int! + $operations: ProductQuestionUpdateInput + ) { + reviewsMutation { + productQuestionUpdate( + productQuestionId: $productQuestionId + expectedRevision: $expectedRevision + operations: $operations + ) { + productQuestion { + ...QuestionEditorFields + } + operationResults { + errors { + code + field + message + } + } + userErrors { + code + field + message + } + } + } + } + ${QUESTION_EDITOR_FRAGMENT} +`; diff --git a/admin/src/domains/customer-content/questions/graphql/operation-types.ts b/admin/src/domains/customer-content/questions/graphql/operation-types.ts new file mode 100644 index 000000000..21abb888f --- /dev/null +++ b/admin/src/domains/customer-content/questions/graphql/operation-types.ts @@ -0,0 +1,72 @@ +import type { + ApiCatalogQuery, + ApiCustomerConnection, + ApiCustomersQuery, + ApiProductConnection, + ApiProductQuestion, + ApiProductQuestionAnswerOrderByInput, + ApiProductQuestionConnection, + ApiProductQuestionCreateInput, + ApiProductQuestionCreatePayload, + ApiProductQuestionOrderByInput, + ApiProductQuestionUpdateInput, + ApiProductQuestionUpdatePayload, + ApiProductQuestionWhereInput, + ApiReviewsMutation, + ApiReviewsQuery, +} from "@/graphql/types"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; + +export interface QuestionsQueryData { + reviewsQuery: Pick & { + productQuestions: ApiProductQuestionConnection; + }; +} + +export interface QuestionsQueryVariables extends RelayCursorPaginationVariables { + where?: ApiProductQuestionWhereInput | null; + orderBy?: ApiProductQuestionOrderByInput[] | null; +} + +export interface QuestionQueryData { + reviewsQuery: Pick & { + productQuestion: ApiProductQuestion | null; + }; +} + +export interface QuestionQueryVariables { + id: string; +} + +export interface QuestionEditorContextQueryData { + catalogQuery: Pick & { + products: ApiProductConnection; + }; + customersQuery: Pick & { + customers: ApiCustomerConnection; + }; +} + +export interface QuestionCreateMutationData { + reviewsMutation: Pick & { + productQuestionCreate: ApiProductQuestionCreatePayload; + }; +} + +export interface QuestionCreateMutationVariables { + input: ApiProductQuestionCreateInput; +} + +export interface QuestionUpdateMutationData { + reviewsMutation: Pick & { + productQuestionUpdate: ApiProductQuestionUpdatePayload; + }; +} + +export interface QuestionUpdateMutationVariables { + productQuestionId: string; + expectedRevision: number; + operations: ApiProductQuestionUpdateInput; +} + +export type { ApiProductQuestionAnswerOrderByInput }; diff --git a/admin/src/domains/customer-content/questions/graphql/queries.ts b/admin/src/domains/customer-content/questions/graphql/queries.ts new file mode 100644 index 000000000..4d96f72bb --- /dev/null +++ b/admin/src/domains/customer-content/questions/graphql/queries.ts @@ -0,0 +1,78 @@ +import { gql } from "@apollo/client"; +import { QUESTION_LIST_FRAGMENT } from "./fragments"; +import { QUESTION_DETAILS_FRAGMENT } from "./details-fragment"; + +export const QUESTIONS_QUERY = gql` + query Questions( + $first: Int + $after: String + $last: Int + $before: String + $where: ProductQuestionWhereInput + $orderBy: [ProductQuestionOrderByInput!] + ) { + reviewsQuery { + productQuestions( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...QuestionListFields + } + } + pageInfo { + startCursor + endCursor + hasPreviousPage + hasNextPage + } + totalCount + } + } + } + ${QUESTION_LIST_FRAGMENT} +`; + +export const QUESTION_QUERY = gql` + query Question($id: ID!) { + reviewsQuery { + productQuestion(id: $id) { + ...QuestionDetailsFields + } + } + } + ${QUESTION_DETAILS_FRAGMENT} +`; + +export const QUESTION_EDITOR_CONTEXT_QUERY = gql` + query QuestionEditorContext { + catalogQuery { + products(first: 250) { + edges { + node { + id + title + } + } + } + } + customersQuery { + customers(first: 250) { + edges { + node { + id + displayName + email + preferredLocale + } + } + } + } + } +`; diff --git a/admin/src/domains/customer-content/questions/hooks/index.ts b/admin/src/domains/customer-content/questions/hooks/index.ts new file mode 100644 index 000000000..39a3156d1 --- /dev/null +++ b/admin/src/domains/customer-content/questions/hooks/index.ts @@ -0,0 +1,5 @@ +export { useQuestions } from "./use-questions"; +export { useQuestion } from "./use-question"; +export { useQuestionEditorContext } from "./use-question-editor-context"; +export { useCreateQuestion } from "./use-create-question"; +export { useUpdateQuestion } from "./use-update-question"; diff --git a/admin/src/domains/customer-content/questions/hooks/use-create-question.ts b/admin/src/domains/customer-content/questions/hooks/use-create-question.ts new file mode 100644 index 000000000..252b73bd2 --- /dev/null +++ b/admin/src/domains/customer-content/questions/hooks/use-create-question.ts @@ -0,0 +1,85 @@ +"use client"; + +import { useCallback } from "react"; +import { useApolloClient, useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiProductQuestion, + ApiProductQuestionAnswerCreateOperationInput, + ApiProductQuestionCreateInput, +} from "@/graphql/types"; +import { + QUESTION_CREATE_MUTATION, + QUESTION_UPDATE_MUTATION, + QUESTIONS_QUERY, +} from "../graphql"; +import type { + QuestionCreateMutationData, + QuestionCreateMutationVariables, + QuestionUpdateMutationData, + QuestionUpdateMutationVariables, +} from "../graphql/operation-types"; + +export function useCreateQuestion() { + const client = useApolloClient(); + const [createQuestionMutation, questionState] = useMutation< + QuestionCreateMutationData, + QuestionCreateMutationVariables + >(QUESTION_CREATE_MUTATION); + const [updateQuestionMutation, answersState] = useMutation< + QuestionUpdateMutationData, + QuestionUpdateMutationVariables + >(QUESTION_UPDATE_MUTATION); + + const createQuestion = useCallback(async ( + input: ApiProductQuestionCreateInput, + answers: ApiProductQuestionAnswerCreateOperationInput[] = [], + ): Promise<{ + question: ApiProductQuestion | null; + userErrors: ApiGenericUserError[]; + }> => { + let question: ApiProductQuestion | null = null; + try { + const result = await createQuestionMutation({ variables: { input } }); + const payload = result.data?.reviewsMutation.productQuestionCreate; + question = payload?.productQuestion ?? null; + const userErrors = [...(payload?.userErrors ?? [])]; + if (!question || userErrors.length > 0) return { question, userErrors }; + + if (answers.length > 0) { + const answerResult = await updateQuestionMutation({ + variables: { + productQuestionId: question.id, + expectedRevision: question.revision, + operations: { answers: { create: answers } }, + }, + }); + const answerPayload = answerResult.data?.reviewsMutation.productQuestionUpdate; + question = answerPayload?.productQuestion ?? question; + userErrors.push( + ...(answerPayload?.userErrors ?? []), + ...(answerPayload?.operationResults.flatMap((item) => item.errors) ?? []), + ); + } + + await client.refetchQueries({ include: [QUESTIONS_QUERY] }); + return { question, userErrors }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Unable to create question"; + return { + question, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] as ApiGenericUserError[], + }; + } + }, [client, createQuestionMutation, updateQuestionMutation]); + + return { + createQuestion, + loading: questionState.loading || answersState.loading, + error: questionState.error ?? answersState.error ?? null, + reset: () => { + questionState.reset(); + answersState.reset(); + }, + }; +} diff --git a/admin/src/domains/customer-content/questions/hooks/use-question-editor-context.ts b/admin/src/domains/customer-content/questions/hooks/use-question-editor-context.ts new file mode 100644 index 000000000..67eeabd0e --- /dev/null +++ b/admin/src/domains/customer-content/questions/hooks/use-question-editor-context.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { QUESTION_EDITOR_CONTEXT_QUERY } from "../graphql"; +import type { QuestionEditorContextQueryData } from "../graphql/operation-types"; + +export function useQuestionEditorContext() { + const { data, previousData, loading, error, refetch } = useQuery< + QuestionEditorContextQueryData + >(QUESTION_EDITOR_CONTEXT_QUERY, { fetchPolicy: "cache-and-network" }); + const result = data ?? previousData; + + return { + context: result + ? { + products: result.catalogQuery.products.edges.map((edge) => edge.node), + customers: result.customersQuery.customers.edges.map((edge) => edge.node), + } + : null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/customer-content/questions/hooks/use-question.ts b/admin/src/domains/customer-content/questions/hooks/use-question.ts new file mode 100644 index 000000000..7e2299850 --- /dev/null +++ b/admin/src/domains/customer-content/questions/hooks/use-question.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { QUESTION_QUERY } from "../graphql"; +import type { QuestionQueryData, QuestionQueryVariables } from "../graphql/operation-types"; + +export function useQuestion(id?: string) { + const { data, previousData, loading, error, refetch } = useQuery< + QuestionQueryData, + QuestionQueryVariables + >(QUESTION_QUERY, { + variables: { id: id ?? "" }, + skip: !id, + fetchPolicy: "cache-and-network", + }); + + return { + question: (data ?? previousData)?.reviewsQuery.productQuestion ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/customer-content/questions/hooks/use-questions.ts b/admin/src/domains/customer-content/questions/hooks/use-questions.ts new file mode 100644 index 000000000..df76b92ca --- /dev/null +++ b/admin/src/domains/customer-content/questions/hooks/use-questions.ts @@ -0,0 +1,47 @@ +"use client"; + +import type { + ApiPageInfo, + ApiProductQuestion, + ApiProductQuestionConnection, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import { QUESTIONS_QUERY } from "../graphql"; +import type { + QuestionsQueryData, + QuestionsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseQuestionsReturn { + questions: ApiProductQuestion[]; + connection: ApiProductQuestionConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useQuestions(variables: QuestionsQueryVariables): UseQuestionsReturn { + const result = useRelayConnectionQuery< + QuestionsQueryData, + QuestionsQueryVariables, + ApiProductQuestion, + ApiProductQuestionConnection + >({ + query: QUESTIONS_QUERY, + variables, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.reviewsQuery.productQuestions, + }); + + return { + questions: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/admin/src/domains/customer-content/questions/hooks/use-update-question.ts b/admin/src/domains/customer-content/questions/hooks/use-update-question.ts new file mode 100644 index 000000000..455692e9e --- /dev/null +++ b/admin/src/domains/customer-content/questions/hooks/use-update-question.ts @@ -0,0 +1,76 @@ +"use client"; + +import { useCallback } from "react"; +import { useApolloClient, useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiProductQuestion, + ApiProductQuestionUpdateInput, +} from "@/graphql/types"; +import { + QUESTION_UPDATE_MUTATION, + QUESTIONS_QUERY, +} from "../graphql"; +import type { + QuestionUpdateMutationData, + QuestionUpdateMutationVariables, +} from "../graphql/operation-types"; +import type { QuestionAnswerMutationPlan } from "../mappers/question-form.mapper"; + +export function useUpdateQuestion() { + const client = useApolloClient(); + const [updateQuestionMutation, questionState] = useMutation< + QuestionUpdateMutationData, + QuestionUpdateMutationVariables + >(QUESTION_UPDATE_MUTATION); + + const updateQuestion = useCallback(async ( + productQuestionId: string, + expectedRevision: number, + operations: ApiProductQuestionUpdateInput, + answerPlan: QuestionAnswerMutationPlan, + ): Promise<{ + question: ApiProductQuestion | null; + userErrors: ApiGenericUserError[]; + }> => { + let question: ApiProductQuestion | null = null; + try { + const hasAnswerChanges = + answerPlan.create.length > 0 || + answerPlan.update.length > 0 || + answerPlan.delete.length > 0; + const result = await updateQuestionMutation({ + variables: { + productQuestionId, + expectedRevision, + operations: hasAnswerChanges + ? { ...operations, answers: answerPlan } + : operations, + }, + }); + const payload = result.data?.reviewsMutation.productQuestionUpdate; + question = payload?.productQuestion ?? null; + const userErrors = [ + ...(payload?.userErrors ?? []), + ...(payload?.operationResults.flatMap((item) => item.errors) ?? []), + ]; + if (!question || userErrors.length > 0) return { question, userErrors }; + + await client.refetchQueries({ include: [QUESTIONS_QUERY] }); + return { question, userErrors }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Unable to update question"; + return { + question, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] as ApiGenericUserError[], + }; + } + }, [client, updateQuestionMutation]); + + return { + updateQuestion, + loading: questionState.loading, + error: questionState.error ?? null, + reset: questionState.reset, + }; +} diff --git a/admin/src/domains/customer-content/questions/mappers/index.ts b/admin/src/domains/customer-content/questions/mappers/index.ts new file mode 100644 index 000000000..5f190ca3d --- /dev/null +++ b/admin/src/domains/customer-content/questions/mappers/index.ts @@ -0,0 +1 @@ +export * from "./question-form.mapper"; diff --git a/admin/src/domains/customer-content/questions/mappers/question-form.mapper.ts b/admin/src/domains/customer-content/questions/mappers/question-form.mapper.ts new file mode 100644 index 000000000..81eb0d281 --- /dev/null +++ b/admin/src/domains/customer-content/questions/mappers/question-form.mapper.ts @@ -0,0 +1,182 @@ +import type { FieldPath } from "react-hook-form"; +import type { + ApiGenericUserError, + ApiProductQuestion, + ApiProductQuestionAnswerCreateOperationInput, + ApiProductQuestionAnswerDeleteOperationInput, + ApiProductQuestionAnswerUpdateOperationInput, + ApiProductQuestionCreateInput, + ApiProductQuestionUpdateInput, + ApiReviewContentAuthorCreateInput, + ApiReviewContentAuthorUpdateInput, +} from "@/graphql/types"; +import { ReviewContentAuthorType } from "@/graphql/types"; +import type { QuestionFormValues } from "../modals/question-modal/schema"; + +type AuthorValues = Pick< + QuestionFormValues, + "authorType" | "customerId" | "authorDisplayName" | "authorEmail" +>; + +function buildAuthor(values: AuthorValues): ApiReviewContentAuthorCreateInput { + return { + type: values.authorType, + customerId: values.authorType === ReviewContentAuthorType.Customer + ? values.customerId || null + : null, + displayName: values.authorDisplayName.trim(), + email: values.authorEmail.trim() || null, + }; +} + +function buildAuthorUpdate(values: AuthorValues): ApiReviewContentAuthorUpdateInput { + return buildAuthor(values); +} + +function buildAnswerAuthor( + answer: QuestionFormValues["answers"][number], +): ApiReviewContentAuthorCreateInput { + return { + type: answer.authorType, + customerId: answer.authorType === ReviewContentAuthorType.Customer + ? answer.customerId || null + : null, + displayName: answer.authorName.trim(), + email: answer.authorEmail.trim() || null, + }; +} + +export function buildQuestionCreateInput(values: QuestionFormValues): ApiProductQuestionCreateInput { + return { + productId: values.productId, + content: { + body: values.body.trim(), + locale: values.locale.trim(), + author: buildAuthor(values), + source: { channel: "ADMIN" }, + status: values.status, + moderationNote: values.moderationNote.trim() || null, + }, + }; +} + +export function buildQuestionCreateAnswers( + values: QuestionFormValues, +): ApiProductQuestionAnswerCreateOperationInput[] { + return values.answers.map((answer, sortIndex) => ({ + content: { + body: answer.body.trim(), + locale: answer.locale.trim(), + author: buildAnswerAuthor(answer), + source: { channel: "ADMIN" }, + status: values.status, + }, + isOfficial: answer.isOfficial, + isAccepted: answer.isAccepted, + sortIndex, + })); +} + +export function buildQuestionUpdateInput(values: QuestionFormValues): ApiProductQuestionUpdateInput { + return { + content: { + text: { body: values.body.trim(), locale: values.locale.trim() }, + author: buildAuthorUpdate(values), + moderation: { + status: values.status, + moderationNote: values.moderationNote.trim() || null, + }, + }, + subject: { productId: values.productId }, + }; +} + +export interface QuestionAnswerMutationPlan { + create: ApiProductQuestionAnswerCreateOperationInput[]; + update: ApiProductQuestionAnswerUpdateOperationInput[]; + delete: ApiProductQuestionAnswerDeleteOperationInput[]; +} + +export function buildQuestionAnswerMutationPlan( + values: QuestionFormValues, + question: ApiProductQuestion, +): QuestionAnswerMutationPlan { + const currentAnswers = question.answers.edges.map((edge) => edge.node); + const submittedIds = new Set(values.answers.flatMap((answer) => answer.id ? [answer.id] : [])); + const create: ApiProductQuestionAnswerCreateOperationInput[] = []; + const update: QuestionAnswerMutationPlan["update"] = []; + + values.answers.forEach((answer, sortIndex) => { + if (!answer.id) { + create.push({ + content: { + body: answer.body.trim(), + locale: answer.locale.trim(), + author: buildAnswerAuthor(answer), + source: { channel: "ADMIN" }, + status: values.status, + }, + isOfficial: answer.isOfficial, + isAccepted: answer.isAccepted, + sortIndex, + }); + return; + } + + const current = currentAnswers.find((item) => item.id === answer.id); + if (!current) return; + update.push({ + answerId: current.id, + expectedRevision: current.revision, + operations: { + content: { + text: { body: answer.body.trim(), locale: answer.locale.trim() }, + author: buildAnswerAuthor(answer), + }, + properties: { + isOfficial: answer.isOfficial, + isAccepted: answer.isAccepted, + sortIndex, + }, + }, + }); + }); + + return { + create, + update, + delete: currentAnswers + .filter((answer) => !submittedIds.has(answer.id)) + .map((answer) => ({ + answerId: answer.id, + expectedRevision: answer.revision, + })), + }; +} + +const fieldMap: Record> = { + "content.author.customerId": "customerId", + "content.author.type": "authorType", + "content.author.displayName": "authorDisplayName", + "content.author.email": "authorEmail", + "content.text.body": "body", + "content.text.locale": "locale", + "content.moderation.status": "status", + "content.moderation.moderationNote": "moderationNote", + "content.body": "body", + "content.locale": "locale", + "content.status": "status", + "content.moderationNote": "moderationNote", + productId: "productId", + "subject.productId": "productId", +}; + +export function mapQuestionUserErrors(errors: ApiGenericUserError[]) { + return errors.map((error) => { + const path = error.field?.join(".") ?? ""; + const field = Object.entries(fieldMap).find( + ([apiPath]) => path === apiPath || path.endsWith(`.${apiPath}`), + )?.[1] ?? null; + return { field, message: error.message }; + }); +} diff --git a/admin/src/domains/customer-content/questions/modals.ts b/admin/src/domains/customer-content/questions/modals.ts new file mode 100644 index 000000000..f65e2f390 --- /dev/null +++ b/admin/src/domains/customer-content/questions/modals.ts @@ -0,0 +1,35 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; + +export const QUESTION_MODAL_TYPE = "customer-question"; +export const QUESTION_CREATE_MODAL_TYPE = "customer-question-create"; +export const QUESTION_EDIT_MODAL_TYPE = "customer-question-edit"; + +export interface QuestionModalPayload extends IModalStackPayload { + entityId: string; + onSaved?: () => Promise | unknown; +} + +export interface QuestionCreateModalPayload extends IModalStackPayload { + onSaved?: () => Promise | unknown; +} + +export type QuestionEditSection = "content" | "moderation" | "answers"; + +export interface QuestionEditModalPayload extends IModalStackPayload { + entityId: string; + section?: QuestionEditSection; + onSaved?: () => Promise | unknown; +} + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [QUESTION_MODAL_TYPE]: QuestionModalPayload; + [QUESTION_CREATE_MODAL_TYPE]: QuestionCreateModalPayload; + [QUESTION_EDIT_MODAL_TYPE]: QuestionEditModalPayload; + } +} + +export const useQuestionModal = createModalStackHook(QUESTION_MODAL_TYPE); +export const useQuestionCreateModal = createModalStackHook(QUESTION_CREATE_MODAL_TYPE); +export const useQuestionEditModal = createModalStackHook(QUESTION_EDIT_MODAL_TYPE); diff --git a/admin/src/domains/customer-content/questions/modals/question-create-modal/index.ts b/admin/src/domains/customer-content/questions/modals/question-create-modal/index.ts new file mode 100644 index 000000000..7a6a00756 --- /dev/null +++ b/admin/src/domains/customer-content/questions/modals/question-create-modal/index.ts @@ -0,0 +1 @@ +export { QuestionCreateModal } from "./question-create-modal"; diff --git a/admin/src/domains/customer-content/questions/modals/question-create-modal/question-create-modal.tsx b/admin/src/domains/customer-content/questions/modals/question-create-modal/question-create-modal.tsx new file mode 100644 index 000000000..d4a940836 --- /dev/null +++ b/admin/src/domains/customer-content/questions/modals/question-create-modal/question-create-modal.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Alert, App, Button, Flex, Input, Select, Typography } from "antd"; +import { LuShoppingBag as ShoppingOutlined, LuUser as UserOutlined } from "react-icons/lu"; +import { ReviewContentAuthorType, ReviewContentStatus } from "@/graphql/types"; +import { shopLocales } from "@/defs/localization"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useEntityPicker } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import "@/shared/components/entity-picker-modal/configs/product-picker-config"; +import "@/domains/customers/all-customers/picker/customer-picker-config"; +import { useCreateQuestion } from "../../hooks"; +import type { QuestionCreateModalPayload } from "../../modals"; + +export function QuestionCreateModal() { + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as QuestionCreateModalPayload; + const mutation = useCreateQuestion(); + const [product, setProduct] = useState(null); + const [customer, setCustomer] = useState(null); + const [locale, setLocale] = useState("en"); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const dirty = !!product || !!customer || locale !== "en" || !!body; + useEffect(() => setDirty(dirty), [dirty, setDirty]); + const productPicker = useEntityPicker({ entityType: "product", selectionMode: "single", initialSelection: product ? [product.id] : [], onConfirm: (items) => setProduct(items[0] ?? null) }); + const customerPicker = useEntityPicker({ entityType: "customer", selectionMode: "single", initialSelection: customer ? [customer.id] : [], onConfirm: (items) => setCustomer(items[0] ?? null) }); + const canSubmit = useMemo(() => !!product && !!customer && body.trim().length >= 10, [body, customer, product]); + const submit = async () => { + if (!product || !customer) return; + setError(null); + const result = await mutation.createQuestion({ productId: product.id, content: { body: body.trim(), locale, author: { type: ReviewContentAuthorType.Customer, customerId: customer.id, displayName: customer.title }, source: { channel: "ADMIN" }, status: ReviewContentStatus.Pending } }); + if (!result.question || result.userErrors.length) return setError(result.userErrors.map((item) => item.message).join(" ") || "Unable to create question"); + await value.onSaved?.(); setDirty(false); message.success("Question created"); forcePop(); + }; + return }> + {mutation.error ? : null}{error ? : null} + +
    Product *
    +
    Customer *
    +
    Locale *
    Customer
    { setAuthorName(event.target.value); change(); }} /> { setAuthorEmail(event.target.value); change(); }} />
    ({ value: item.value, label: item.name }))} onChange={(next) => updateAnswer(index, "locale", next)} style={{ width: 160 }} /> updateAnswer(index, "authorName", event.target.value)} /> updateAnswer(index, "isOfficial", event.target.checked)}>Official answer updateAnswer(index, "isAccepted", event.target.checked)}>Accepted answer)} : null} +
    ; +} diff --git a/admin/src/domains/customer-content/questions/modals/question-modal/schema.ts b/admin/src/domains/customer-content/questions/modals/question-modal/schema.ts new file mode 100644 index 000000000..aea786a5e --- /dev/null +++ b/admin/src/domains/customer-content/questions/modals/question-modal/schema.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { + ReviewContentAuthorType, + ReviewContentStatus, +} from "@/graphql/types"; + +const answerSchema = z.object({ + id: z.string().optional(), + revision: z.number().int().positive().optional(), + body: z.string().trim().min(10, "Answer must contain at least 10 characters").max(5000), + locale: z.string().trim().min(2, "Locale is required").max(35), + authorType: z.enum(ReviewContentAuthorType), + customerId: z.string(), + authorName: z.string().trim().min(1, "Author name is required").max(150), + authorEmail: z.string().trim().email("Enter a valid email").or(z.literal("")), + isOfficial: z.boolean(), + isAccepted: z.boolean(), +}).superRefine((values, context) => { + if (values.authorType === ReviewContentAuthorType.Customer && !values.customerId) { + context.addIssue({ + code: "custom", + path: ["customerId"], + message: "Customer is required for a customer author", + }); + } +}); + +export const questionFormSchema = z.object({ + productId: z.string().min(1, "Product is required"), + authorType: z.enum(ReviewContentAuthorType), + customerId: z.string(), + authorDisplayName: z.string().trim().min(1, "Author name is required").max(150), + authorEmail: z.string().trim().email("Enter a valid email").or(z.literal("")), + locale: z.string().trim().min(2, "Locale is required").max(35), + body: z.string().trim().min(10, "Question must contain at least 10 characters").max(5000), + status: z.enum(ReviewContentStatus), + moderationNote: z.string().trim().max(1000, "Moderation note must be at most 1,000 characters"), + answers: z.array(answerSchema).max(50, "A question can contain at most 50 answers"), +}).superRefine((values, context) => { + if (values.authorType === ReviewContentAuthorType.Customer && !values.customerId) { + context.addIssue({ + code: "custom", + path: ["customerId"], + message: "Customer is required for a customer author", + }); + } + if (values.status === ReviewContentStatus.Rejected && !values.moderationNote) { + context.addIssue({ code: "custom", path: ["moderationNote"], message: "Add a reason when rejecting a question" }); + } +}); + +export type QuestionFormValues = z.infer; diff --git a/admin/src/domains/customer-content/questions/page/filter-schema.ts b/admin/src/domains/customer-content/questions/page/filter-schema.ts new file mode 100644 index 000000000..5921947a3 --- /dev/null +++ b/admin/src/domains/customer-content/questions/page/filter-schema.ts @@ -0,0 +1,66 @@ +import { + FilterType, + dateOperators, + enumOperators, + numberOperators, + relationOperators, +} from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { + ProductQuestionAnswerState, + ReviewContentStatus, +} from "@/graphql/types"; + +/** Required operational filters for question answering and moderation queues. */ +export const filterSchema: IFilterSchema[] = [ + { + key: "status", + label: "Status", + description: "Filter by moderation status", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "status", + options: [ + { label: "Pending", value: ReviewContentStatus.Pending }, + { label: "Published", value: ReviewContentStatus.Published }, + { label: "Rejected", value: ReviewContentStatus.Rejected }, + ], + }, + { + key: "answerState", + label: "Answer status", + description: "Find questions that still need an answer", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "answerState", + options: [ + { label: "Unanswered", value: ProductQuestionAnswerState.Unanswered }, + { label: "Answered", value: ProductQuestionAnswerState.Answered }, + ], + }, + { + key: "product", + label: "Product", + description: "Filter questions for selected products", + type: FilterType.Relation, + operators: relationOperators, + payloadKey: "productId", + entity: "product", + }, + { + key: "reportCount", + label: "Abuse reports", + description: "Filter by the number of customer abuse reports", + type: FilterType.Number, + operators: numberOperators, + payloadKey: "reportCount", + }, + { + key: "createdAt", + label: "Submitted date", + description: "Filter by question submission date", + type: FilterType.DateRange, + operators: dateOperators, + payloadKey: "createdAt", + }, +]; diff --git a/admin/src/domains/customer-content/questions/page/page-config.ts b/admin/src/domains/customer-content/questions/page/page-config.ts new file mode 100644 index 000000000..438190342 --- /dev/null +++ b/admin/src/domains/customer-content/questions/page/page-config.ts @@ -0,0 +1,40 @@ +import type { SortFieldMapping, UsePageConfigReturn } from "@/hooks"; +import type { + ApiProductQuestionOrderByInput, + ApiProductQuestionWhereInput, +} from "@/graphql/types"; +import { ProductQuestionOrderField } from "@/graphql/types"; +import type { QuestionsQueryVariables } from "../graphql/operation-types"; + +export const questionSortFieldMapping: SortFieldMapping = { + answerCount: ProductQuestionOrderField.AnswerCount, + createdAt: ProductQuestionOrderField.CreatedAt, + reportCount: ProductQuestionOrderField.ReportCount, + status: ProductQuestionOrderField.Status, + updatedAt: ProductQuestionOrderField.UpdatedAt, +}; + +export const buildQuestionSearchCondition = ( + search: string, +): Partial => ({ + _or: [{ body: { _containsi: search } }], +}); + +export function buildQuestionsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): QuestionsQueryVariables { + return { + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: pageConfig.orderBy?.map((order) => ({ + field: order.field, + direction: order.direction, + })) as ApiProductQuestionOrderByInput[] | undefined, + }; +} diff --git a/admin/src/domains/customer-content/questions/page/page.tsx b/admin/src/domains/customer-content/questions/page/page.tsx new file mode 100644 index 000000000..79b367f69 --- /dev/null +++ b/admin/src/domains/customer-content/questions/page/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, Button, Flex, Tag, Typography } from "antd"; +import { LuThumbsDown as DislikeOutlined, LuFlag as FlagOutlined, LuThumbsUp as LikeOutlined, LuMessageSquare as MessageOutlined, LuPlus as PlusOutlined, LuShoppingBag as ShoppingOutlined } from "react-icons/lu"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { AllCommunityModule, GridStateModule, ModuleRegistry, type ColDef } from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { ApiProductQuestion, ApiProductQuestionWhereInput } from "@/graphql/types"; +import { + ProductQuestionAnswerState, + ProductQuestionOrderField, + ReviewContentStatus, +} from "@/graphql/types"; +import { useQuestions } from "../hooks"; +import { useQuestionCreateModal, useQuestionModal } from "../modals"; +import { useUgcNavigation } from "../../use-ugc-navigation"; +import { filterSchema } from "./filter-schema"; +import { buildQuestionSearchCondition, buildQuestionsQueryVariables, questionSortFieldMapping } from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); + +const statusConfig: Record = { + [ReviewContentStatus.Pending]: { color: "gold", label: "Pending" }, + [ReviewContentStatus.Published]: { color: "green", label: "Published" }, + [ReviewContentStatus.Rejected]: { color: "red", label: "Rejected" }, +}; + +function QuestionCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return {data.body}; +} + +function ProductCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return ( + + + {data.product.title} + + ); +} + +function CustomerCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return ( + + {data.author.displayName} + {data.author.email ?? data.author.type} + + ); +} + +function AnswerCell({ data }: CustomCellRendererProps) { + if (!data) return null; + const answered = data.answerState === ProductQuestionAnswerState.Answered; + return ( + + + {answered ? `${data.metrics.childCount} answered` : "Unanswered"} + + ); +} + +function StatusCell({ value }: CustomCellRendererProps) { + const config = statusConfig[value ?? ReviewContentStatus.Pending]; + return {config.label}; +} + +function SignalsCell({ data }: CustomCellRendererProps) { + if (!data) return null; + return ( + + {data.metrics.likeCount} + {data.metrics.dislikeCount} + + + {data.metrics.reportCount} + + + ); +} + +const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }); +function DateCell({ value }: CustomCellRendererProps) { + return {value ? dateFormatter.format(new Date(value)) : ""}; +} + +export default function CustomerQuestionsPage() { + const { backToUgc } = useUgcNavigation(); + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig({ + gridRef, + storageKey: "questions-grid-state", + filterSchema, + sortFieldMapping: questionSortFieldMapping, + defaultSort: [{ colId: "createdAt", sort: "desc" }], + defaultPageSize: 10, + pageSizeOptions: [10, 20], + buildSearchCondition: buildQuestionSearchCondition, + }); + const variables = useMemo(() => buildQuestionsQueryVariables({ first: pageConfig.first, after: pageConfig.after, last: pageConfig.last, before: pageConfig.before, where: pageConfig.where, orderBy: pageConfig.orderBy }), [pageConfig.first, pageConfig.after, pageConfig.last, pageConfig.before, pageConfig.where, pageConfig.orderBy]); + const { questions, totalCount, pageInfo, loading, error, refetch } = useQuestions(variables); + const { push: openQuestionModal } = useQuestionModal(); + const { push: openQuestionCreateModal } = useQuestionCreateModal(); + const createQuestion = useCallback(() => openQuestionCreateModal({ onSaved: refetch }), [openQuestionCreateModal, refetch]); + const editQuestion = useCallback((question: ApiProductQuestion) => openQuestionModal({ entityId: question.id, onSaved: refetch }), [openQuestionModal, refetch]); + const nextPage = useCallback(() => { if (pageInfo?.endCursor) pageConfig.goToNextPage(pageInfo.endCursor); }, [pageConfig, pageInfo?.endCursor]); + const previousPage = useCallback(() => { if (pageInfo?.startCursor) pageConfig.goToPrevPage(pageInfo.startCursor); }, [pageConfig, pageInfo?.startCursor]); + + const columnDefs = useMemo[]>(() => [ + { headerName: "Question", field: "body", cellRenderer: QuestionCell, minWidth: 340, flex: 2, sortable: false }, + { headerName: "Product", colId: "product", cellRenderer: ProductCell, minWidth: 230, flex: 1, sortable: false }, + { headerName: "Customer", colId: "customer", cellRenderer: CustomerCell, minWidth: 210, sortable: false }, + { headerName: "Answers", colId: "answerCount", cellRenderer: AnswerCell, minWidth: 145 }, + { headerName: "Status", field: "status", cellRenderer: StatusCell, width: 125 }, + { headerName: "Signals", colId: "signals", cellRenderer: SignalsCell, minWidth: 210, sortable: false }, + { headerName: "Submitted", field: "createdAt", cellRenderer: DateCell, minWidth: 180 }, + ], []); + const defaultColDef = useMemo>(() => ({ resizable: true, sortable: true, comparator: () => 0, cellStyle: { display: "flex", alignItems: "center" } }), []); + + return ( + } onClick={createQuestion}>Create question}> + } /> +
    + {error ? : null} +
    + + ref={gridRef} + theme={agGridTheme} + rowData={questions} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={68} + loading={loading} + suppressMovableColumns + rowStyle={{ cursor: "pointer" }} + onRowClicked={({ data }) => data && editQuestion(data)} + onSortChanged={pageConfig.onSortChanged} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + suppressCellFocus + /> +
    + +
    +
    + ); +} diff --git a/admin/src/domains/customer-content/register.tsx b/admin/src/domains/customer-content/register.tsx new file mode 100644 index 000000000..e1aab9d65 --- /dev/null +++ b/admin/src/domains/customer-content/register.tsx @@ -0,0 +1,118 @@ +import { registerModule } from "@/registry"; +import { LuBell as NotificationOutlined } from "react-icons/lu"; +import dynamic from "next/dynamic"; + +registerModule({ + key: "marketing", + domain: "store", + sidebar: { + label: "Marketing", + icon: , + order: 9, + }, + items: [ + { + key: "customer-content", + path: "/:orgName/:storeName/customer-content", + sidebar: { + label: "UGC", + icon: null, + order: 1, + activePaths: [ + "/:orgName/:storeName/customer-content/reviews", + "/:orgName/:storeName/customer-content/questions", + "/:orgName/:storeName/customer-content/moderation", + "/:orgName/:storeName/customer-content/reports", + "/:orgName/:storeName/customer-content/cases", + "/:orgName/:storeName/customer-content/review-requests", + "/:orgName/:storeName/customer-content/external-sync", + "/:orgName/:storeName/customer-content/settings", + ], + }, + component: dynamic(() => import("@/domains/customer-content/page/page")), + }, + { + key: "customer-reviews-list", + path: "/:orgName/:storeName/customer-content/reviews", + component: dynamic( + () => import("@/domains/customer-content/reviews/page/page"), + ), + }, + { + key: "customer-questions-list", + path: "/:orgName/:storeName/customer-content/questions", + component: dynamic( + () => import("@/domains/customer-content/questions/page/page"), + ), + }, + { + key: "customer-content-moderation", + path: "/:orgName/:storeName/customer-content/moderation", + component: dynamic(() => import("@/domains/customer-content/management/moderation/page")), + }, + { + key: "customer-content-reports", + path: "/:orgName/:storeName/customer-content/reports", + component: dynamic(() => import("@/domains/customer-content/management/reports/page")), + }, + { + key: "customer-content-cases", + path: "/:orgName/:storeName/customer-content/cases", + component: dynamic(() => import("@/domains/customer-content/management/cases/page")), + }, + { + key: "review-requests", + path: "/:orgName/:storeName/customer-content/review-requests", + component: dynamic(() => import("@/domains/customer-content/management/requests/page")), + }, + { + key: "review-external-sync", + path: "/:orgName/:storeName/customer-content/external-sync", + component: dynamic(() => import("@/domains/customer-content/management/external/page")), + }, + { + key: "review-settings", + path: "/:orgName/:storeName/customer-content/settings", + component: dynamic(() => import("@/domains/customer-content/management/settings/page")), + }, + { + key: "discounts-list", + path: "/:orgName/:storeName/discounts", + disabled: false, + sidebar: { + label: "Discounts", + icon: null, + order: 11, + }, + component: dynamic( + () => import("@/domains/inventory/discounts/page/page"), + ), + }, + { + key: "coupons-list", + path: "/:orgName/:storeName/coupons", + disabled: true, + sidebar: { + label: "Coupons", + icon: null, + order: 12, + }, + component: dynamic( + () => import("@/domains/inventory/coupons/page/page"), + ), + }, + { + key: "campaigns-list", + path: "/:orgName/:storeName/campaigns", + disabled: true, + sidebar: { + label: "Campaigns", + icon: null, + order: 13, + }, + component: dynamic( + () => import("@/domains/inventory/campaigns/page/page"), + ), + }, + ], +}); diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/index.ts b/admin/src/domains/customer-content/reviews/components/review-details-card/index.ts new file mode 100644 index 000000000..c31a15f12 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/index.ts @@ -0,0 +1 @@ +export { ReviewDetailsCard } from "./review-details-card"; diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.styles.ts b/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.styles.ts new file mode 100644 index 000000000..cbc8d179a --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.styles.ts @@ -0,0 +1,254 @@ +import { createStyles } from "antd-style"; + +export const useReviewDetailsStyles = createStyles(({ token }) => ({ + headerTop: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: token.marginSM, + }, + headerMeta: { + minWidth: 0, + }, + statusTag: { + margin: 0, + display: "inline-flex", + alignItems: "center", + gap: 4, + fontWeight: 500, + }, + auditText: { + fontSize: token.fontSizeSM, + }, + actionButton: { + paddingInline: token.paddingXS, + }, + reviewTitle: { + margin: "0 !important", + }, + ratingLine: { + "& .ant-rate": { + fontSize: 20, + whiteSpace: "nowrap", + }, + }, + divider: { + marginBlock: token.margin, + }, + kpiGrid: { + display: "grid", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gap: token.marginSM, + "@media (max-width: 640px)": { + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + }, + "@media (max-width: 420px)": { + gridTemplateColumns: "1fr", + }, + }, + reviewBody: { + whiteSpace: "pre-wrap", + lineHeight: 1.7, + marginBottom: token.margin, + }, + disclosure: { + padding: token.paddingSM, + border: `1px solid ${token.colorInfoBorder}`, + borderRadius: token.borderRadius, + background: token.colorInfoBg, + marginBottom: token.margin, + }, + disclosureIcon: { + color: token.colorInfo, + fontSize: 18, + }, + sectionMeta: { + paddingTop: token.paddingSM, + borderTop: `1px solid ${token.colorBorderSecondary}`, + }, + statusStrip: { + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadius, + overflow: "hidden", + }, + statusSegment: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: token.marginXS, + minHeight: 46, + color: token.colorTextSecondary, + borderRight: `1px solid ${token.colorBorder}`, + "&:last-child": { borderRight: 0 }, + }, + statusSegmentActivePending: { + color: token.colorWarningText, + background: token.colorWarningBg, + boxShadow: `inset 0 0 0 1px ${token.colorWarning}`, + }, + statusSegmentActivePublished: { + color: token.colorSuccessText, + background: token.colorSuccessBg, + boxShadow: `inset 0 0 0 1px ${token.colorSuccess}`, + }, + statusSegmentActiveRejected: { + color: token.colorErrorText, + background: token.colorErrorBg, + boxShadow: `inset 0 0 0 1px ${token.colorError}`, + }, + noteBlock: { + padding: token.paddingSM, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + }, + verificationRow: { + padding: token.paddingSM, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + }, + reportsHeader: { + paddingTop: token.paddingSM, + borderTop: `1px solid ${token.colorBorderSecondary}`, + }, + reportRow: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) auto", + gap: token.marginSM, + paddingBlock: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0 }, + "@media (max-width: 640px)": { + gridTemplateColumns: "1fr", + }, + }, + subjectSummary: { + paddingBottom: token.paddingSM, + }, + subjectIcon: { + width: 44, + height: 44, + borderRadius: token.borderRadius, + display: "flex", + alignItems: "center", + justifyContent: "center", + background: token.colorBgLayout, + color: token.colorTextSecondary, + fontSize: 20, + flexShrink: 0, + }, + orderGrid: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: token.margin, + paddingBlock: token.paddingSM, + borderTop: `1px solid ${token.colorBorderSecondary}`, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "@media (max-width: 640px)": { + gridTemplateColumns: "1fr", + }, + }, + ratingsGrid: { + display: "grid", + gridTemplateColumns: "minmax(180px, 1fr) minmax(0, 2fr)", + gap: token.margin, + "@media (max-width: 640px)": { + gridTemplateColumns: "1fr", + }, + }, + overallRating: { + padding: token.padding, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + textAlign: "center", + }, + overallValue: { + fontSize: 42, + fontWeight: 600, + lineHeight: 1.1, + }, + criteriaPanel: { + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + overflow: "hidden", + }, + criteriaHeader: { + padding: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + }, + criterionRow: { + display: "grid", + gridTemplateColumns: "minmax(100px, 1fr) minmax(100px, 1fr) 36px", + alignItems: "center", + gap: token.marginSM, + padding: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0 }, + }, + criterionScale: { + display: "grid", + gridTemplateColumns: "repeat(5, minmax(10px, 1fr))", + gap: 4, + }, + criterionSegment: { + height: 6, + borderRadius: 999, + background: token.colorFillSecondary, + }, + criterionSegmentActive: { + background: token.colorWarning, + }, + mediaBadge: { + width: 24, + height: 24, + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: token.colorTextLightSolid, + background: token.colorBgMask, + boxShadow: token.boxShadowTertiary, + }, + mediaBadgePublished: { color: token.colorSuccess }, + mediaBadgePending: { color: token.colorWarning }, + mediaBadgeRejected: { color: token.colorError }, + mediaFooter: { + marginTop: token.margin, + paddingTop: token.paddingSM, + borderTop: `1px solid ${token.colorBorderSecondary}`, + }, + replyRow: { + paddingBlock: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0 }, + }, + replyBodyCollapsed: { + display: "-webkit-box", + WebkitLineClamp: 4, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }, + externalRow: { + display: "grid", + gridTemplateColumns: "auto minmax(0, 1fr) auto", + gap: token.marginSM, + alignItems: "flex-start", + paddingBlock: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0 }, + "@media (max-width: 640px)": { + gridTemplateColumns: "auto minmax(0, 1fr)", + }, + }, + technicalCode: { + margin: 0, + padding: token.paddingSM, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + background: token.colorBgLayout, + overflowX: "auto", + fontFamily: "ui-monospace, SFMono-Regular, monospace", + fontSize: token.fontSizeSM, + }, +})); diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.tsx new file mode 100644 index 000000000..fea4ec1cd --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { App, Flex } from "antd"; +import type { ApiReview, ApiReviewContentExternalReference, ApiReviewMedia } from "@/graphql/types"; +import type { ReviewEditSection } from "../../modals"; +import { ReviewInfoHeader } from "./review-info-header"; +import { + ReviewContentSection, + ReviewExternalReferencesSection, + ReviewMediaSection, + ReviewModerationSection, + ReviewRatingsSection, + ReviewRepliesSection, + ReviewSubjectSection, +} from "./sections"; + +interface ReviewDetailsCardProps { + review: ApiReview; + onEdit: (section: ReviewEditSection) => void; + onDelete: () => Promise | void; + onRedact: () => Promise | void; + onOpenTechnicalMetadata: () => void; + onOpenProduct: () => void; + onOpenMediaItem: (item: ApiReviewMedia) => void; + onAddExternalReference: () => void; + onEditExternalReference: (reference: ApiReviewContentExternalReference) => void; +} + +export function ReviewDetailsCard({ + review, + onEdit, + onDelete, + onRedact, + onOpenTechnicalMetadata, + onOpenProduct, + onOpenMediaItem, + onAddExternalReference, + onEditExternalReference, +}: ReviewDetailsCardProps) { + const { modal } = App.useApp(); + + const confirmRedact = () => { + modal.confirm({ + title: "Redact personal content?", + content: "Personal author data and redactable review content will be irreversibly replaced. This action cannot be undone.", + okText: "Redact", + okButtonProps: { danger: true }, + onOk: onRedact, + }); + }; + + const confirmDelete = () => { + modal.confirm({ + title: "Delete review?", + content: "The review will be archived with a soft delete and removed from active review surfaces.", + okText: "Delete", + okButtonProps: { danger: true }, + onOk: onDelete, + }); + }; + + return ( + + + + + + + + + + + ); +} diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.utils.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.utils.tsx new file mode 100644 index 000000000..ddc10202b --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/review-details-card.utils.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import { Tooltip } from "antd"; +import { + LuCircleCheck as CheckCircleOutlined, + LuClock as ClockCircleOutlined, + LuCircleX as CloseCircleOutlined, +} from "react-icons/lu"; +import { ReviewContentStatus } from "@/graphql/types"; +import { formatDetailDate } from "@/domains/inventory/utils/format-detail-date"; +import { useReviewDetailsStyles } from "./review-details-card.styles"; + +export const reviewStatusConfig: Record = { + [ReviewContentStatus.Pending]: { + label: "PENDING", + color: "gold", + hint: "Awaiting moderation", + icon: , + }, + [ReviewContentStatus.Published]: { + label: "PUBLISHED", + color: "green", + hint: "Visible in published review surfaces", + icon: , + }, + [ReviewContentStatus.Rejected]: { + label: "REJECTED", + color: "red", + hint: "Rejected by moderation", + icon: , + }, +}; + +export function formatReviewDateTime(value?: string | null) { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +export function formatReviewDate(value?: string | null) { + return formatDetailDate(value) || "—"; +} + +export function humanizeEnum(value: string) { + return value + .toLowerCase() + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function ReviewStatusStrip({ status }: { status: ReviewContentStatus }) { + const { styles, cx } = useReviewDetailsStyles(); + return ( +
    + {Object.values(ReviewContentStatus).map((value) => { + const config = reviewStatusConfig[value]; + const activeClass = value === ReviewContentStatus.Pending + ? styles.statusSegmentActivePending + : value === ReviewContentStatus.Published + ? styles.statusSegmentActivePublished + : styles.statusSegmentActiveRejected; + return ( +
    + {config.icon} + {humanizeEnum(value)} +
    + ); + })} +
    + ); +} + +export function MediaStatusBadge({ status }: { status: ReviewContentStatus }) { + const { styles, cx } = useReviewDetailsStyles(); + const config = reviewStatusConfig[status]; + const className = status === ReviewContentStatus.Pending + ? styles.mediaBadgePending + : status === ReviewContentStatus.Published + ? styles.mediaBadgePublished + : styles.mediaBadgeRejected; + return ( + + + {config.icon} + + + ); +} diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/review-info-header.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/review-info-header.tsx new file mode 100644 index 000000000..7c5f1f2b6 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/review-info-header.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { Button, Divider, Dropdown, Flex, Rate, Tag, Tooltip, Typography } from "antd"; +import { + LuCheck as CheckOutlined, + LuEllipsis as MoreOutlined, + LuLink as LinkOutlined, + LuShieldCheck as SafetyCertificateOutlined, +} from "react-icons/lu"; +import type { ApiReview } from "@/graphql/types"; +import { CopyableChip } from "@/ui-kit/copyable-chip"; +import { KPITile } from "@/ui-kit/kpi-tile"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { ReviewEditSection } from "../../modals"; +import { useReviewDetailsStyles } from "./review-details-card.styles"; +import { + formatReviewDate, + humanizeEnum, + reviewStatusConfig, +} from "./review-details-card.utils"; + +interface ReviewInfoHeaderProps { + review: ApiReview; + onEdit: (section: ReviewEditSection) => void; + onOpenTechnicalMetadata: () => void; + onRedact: () => void; + onDelete: () => void; +} + +export function ReviewInfoHeader({ + review, + onEdit, + onOpenTechnicalMetadata, + onRedact, + onDelete, +}: ReviewInfoHeaderProps) { + const { styles } = useReviewDetailsStyles(); + const [linkCopied, setLinkCopied] = useState(false); + const status = reviewStatusConfig[review.status]; + const authorName = review.author.displayName || "Anonymous reviewer"; + const audit = [ + `Created ${formatReviewDate(review.createdAt)}`, + review.updatedAt ? `Updated ${formatReviewDate(review.updatedAt)}` : null, + humanizeEnum(review.sourceChannel), + ].filter(Boolean).join(" · "); + + const copyAdminUrl = async () => { + await navigator.clipboard.writeText(window.location.href); + setLinkCopied(true); + window.setTimeout(() => setLinkCopied(false), 1500); + }; + + return ( + + + + + {status.label} + + + + {audit} + + + )} + actions={( + + + } + /> + {references.length ? references.map((reference) => ( +
    + {humanizeEnum(reference.syncStatus).toUpperCase()} + + + {reference.externalSystem} · {reference.externalType} · {reference.externalId} + + + + {reference.syncStatus === ReviewExternalSyncStatus.Failed ? "Sync failed" : "Last synced"} {formatReviewDateTime(reference.lastSyncedAt ?? reference.updatedAt)} + + {reference.externalUrl ? ( + + Open external link + + ) : null} + + {reference.lastError ? {reference.lastError} : null} + + onEdit(reference)} label="Edit external reference" testId={`review-external-${reference.id}`} /> +
    + )) : ( + } + state={{ title: "No external references", description: "Connect this review to an external review system." }} + /> + )} + {!references.length ? : null} +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-media-section.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-media-section.tsx new file mode 100644 index 000000000..0671f54d0 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-media-section.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Flex, Typography } from "antd"; +import { LuInfo as InfoCircleOutlined, LuImage as PictureOutlined } from "react-icons/lu"; +import type { ApiFile, ApiReview, ApiReviewMedia } from "@/graphql/types"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import { MediaSection } from "@/domains/inventory/products/components/product-details-card/sections"; +import type { ReviewEditSection } from "../../../modals"; +import { useReviewDetailsStyles } from "../review-details-card.styles"; +import { MediaStatusBadge } from "../review-details-card.utils"; + +interface ReviewMediaSectionProps { + review: ApiReview; + onEdit: (section: ReviewEditSection) => void; + onOpenItem: (item: ApiReviewMedia) => void; +} + +export function ReviewMediaSection({ review, onEdit, onOpenItem }: ReviewMediaSectionProps) { + const { styles } = useReviewDetailsStyles(); + const metadataByFileId = new Map(review.media.map((item) => [item.file.id, item])); + const openFile = (file: ApiFile) => { + const item = metadataByFileId.get(file.id); + if (item) onOpenItem(item); + }; + + return ( + item.file)} + onEdit={() => onEdit("media")} + onOpenItem={openFile} + renderItemBadge={(file) => { + const item = metadataByFileId.get(file.id); + return item ? : null; + }} + title={`Customer media (${review.media.length})`} + editLabel="Edit customer media" + hasFeatured={false} + testIdPrefix="review-media" + emptyState={( + } + state={{ title: "No customer media", description: "This review has no attached photos or videos." }} + /> + )} + footer={review.media.length ? ( + + + Select an item to preview or moderate it. + + ) : null} + /> + ); +} diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-moderation-section.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-moderation-section.tsx new file mode 100644 index 000000000..3c4ca8cd6 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-moderation-section.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Button, Flex, Tag, Typography } from "antd"; +import { LuShieldCheck as SafetyCertificateOutlined } from "react-icons/lu"; +import type { ApiReview, ApiReviewContentReport } from "@/graphql/types"; +import { ReviewContentReportStatus, ReviewVerificationStatus } from "@/graphql/types"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { ReviewEditSection } from "../../../modals"; +import { useReviewDetailsStyles } from "../review-details-card.styles"; +import { formatReviewDateTime, humanizeEnum, ReviewStatusStrip } from "../review-details-card.utils"; + +interface ReviewModerationSectionProps { + review: ApiReview; + onEdit: (section: ReviewEditSection) => void; +} + +const reportStatusColor: Record = { + [ReviewContentReportStatus.Open]: "red", + [ReviewContentReportStatus.UnderReview]: "gold", + [ReviewContentReportStatus.Actioned]: "green", + [ReviewContentReportStatus.Dismissed]: undefined, +}; + +const openStatuses = new Set([ + ReviewContentReportStatus.Open, + ReviewContentReportStatus.UnderReview, +]); + +function compareReports(a: ApiReviewContentReport, b: ApiReviewContentReport) { + const group = Number(openStatuses.has(a.status)) - Number(openStatuses.has(b.status)); + if (group !== 0) return -group; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); +} + +function ReportRow({ report }: { report: ApiReviewContentReport }) { + const { styles } = useReviewDetailsStyles(); + const reporter = report.reporterCustomer; + return ( +
    + + + {humanizeEnum(report.status).toUpperCase()} + {humanizeEnum(report.reason)} + + + {reporter ? `${reporter.displayName}${reporter.email ? ` · ${reporter.email}` : ""}` : "Anonymous reporter"} + + {report.details ? “{report.details}” : null} + + {formatReviewDateTime(report.createdAt)} +
    + ); +} + +export function ReviewModerationSection({ review, onEdit }: ReviewModerationSectionProps) { + const { styles } = useReviewDetailsStyles(); + const [showAll, setShowAll] = useState(false); + const reports = useMemo( + () => review.reports.edges.map((edge) => edge.node).sort(compareReports), + [review.reports.edges], + ); + const visibleReports = showAll ? reports : reports.slice(0, 3); + const verificationColor = review.verificationStatus === ReviewVerificationStatus.Verified + ? "green" + : review.verificationStatus === ReviewVerificationStatus.Revoked + ? "red" + : undefined; + + return ( + + onEdit("moderation")} + label="Review moderation" + testId="review-moderation-actions" + items={[ + { key: "moderation", label: "Review moderation", "data-testid": "review-moderation-edit", onClick: () => onEdit("moderation") }, + { key: "verification", label: "Edit purchase verification", "data-testid": "review-verification-edit", onClick: () => onEdit("verification") }, + ]} + /> + )} + /> + +
    + Moderation status +
    + {review.moderatedAt ? ( + + Moderated {formatReviewDateTime(review.moderatedAt)}{review.moderatedByPrincipalId ? ` by ${review.moderatedByPrincipalId}` : ""} + + ) : null} +
    +
    + Internal note +
    + + {review.moderationNote || "No internal note"} + +
    +
    + + + Purchase verification + {humanizeEnum(review.verificationStatus).toUpperCase()} + {review.verificationMethod ? {review.verificationMethod} : null} + {review.verifiedAt ? · {formatReviewDateTime(review.verifiedAt)} : null} + +
    + + Abuse reports + {review.metrics.openReportCount} open / {review.metrics.reportCount} total + + {visibleReports.length ? visibleReports.map((report) => ) : ( + + )} + {reports.length > 3 ? ( + + ) : null} +
    +
    +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-ratings-section.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-ratings-section.tsx new file mode 100644 index 000000000..da4c304bf --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-ratings-section.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Button, Flex, Rate, Tag, Typography } from "antd"; +import { LuPencil as EditOutlined } from "react-icons/lu"; +import type { ApiReview } from "@/graphql/types"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { ReviewEditSection } from "../../../modals"; +import { useReviewDetailsStyles } from "../review-details-card.styles"; + +interface ReviewRatingsSectionProps { + review: ApiReview; + onEdit: (section: ReviewEditSection) => void; +} + +export function ReviewRatingsSection({ review, onEdit }: ReviewRatingsSectionProps) { + const { styles, cx } = useReviewDetailsStyles(); + return ( + + Submitted by the reviewer} + actions={} + /> +
    + + Overall rating + {review.rating.toFixed(1)} + + {review.rating} out of 5 + +
    + + + Criteria breakdown + Individual scores for this review + + {review.ratings.length ? {review.ratings.length} criteria : null} + + {review.ratings.length ? review.ratings.map((item) => ( +
    + {item.criterion.defaultTitle} +
    + {Array.from({ length: 5 }, (_, index) => ( + + ))} +
    + {item.value.toFixed(1)} +
    + )) : ( + + )} +
    +
    +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-replies-section.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-replies-section.tsx new file mode 100644 index 000000000..13cd810df --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-replies-section.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { Button, Flex, Tag, Typography } from "antd"; +import type { ApiReview, ApiReviewReply } from "@/graphql/types"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useReviewDetailsStyles } from "../review-details-card.styles"; +import { formatReviewDateTime, humanizeEnum } from "../review-details-card.utils"; + +function ReplyRow({ reply }: { reply: ApiReviewReply }) { + const { styles, cx } = useReviewDetailsStyles(); + const [expanded, setExpanded] = useState(false); + const canExpand = reply.body.length > 240; + return ( +
    + + + {reply.author.displayName || "Anonymous"} + {reply.isOfficial ? "Official" : humanizeEnum(reply.author.type)} + + {humanizeEnum(reply.status)} + + + {formatReviewDateTime(reply.createdAt)} + + + {reply.body} + + {canExpand ? : null} + + {reply.metrics.likeCount} helpful · {reply.metrics.reportCount} {reply.metrics.reportCount === 1 ? "report" : "reports"} + +
    + ); +} + +export function ReviewRepliesSection({ review }: { review: ApiReview }) { + const [showAll, setShowAll] = useState(false); + const replies = review.replies.edges.map((edge) => edge.node); + const visibleReplies = showAll ? replies : replies.slice(0, 5); + return ( + + + {visibleReplies.length ? visibleReplies.map((reply) => ) : ( + + )} + {replies.length > 5 ? ( + + + + ) : null} + + ); +} diff --git a/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-subject-section.tsx b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-subject-section.tsx new file mode 100644 index 000000000..f21ef9d0e --- /dev/null +++ b/admin/src/domains/customer-content/reviews/components/review-details-card/sections/review-subject-section.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Button, Flex, Typography } from "antd"; +import { LuExternalLink as ExportOutlined, LuShoppingBag as ShoppingOutlined } from "react-icons/lu"; +import type { ApiReview } from "@/graphql/types"; +import { CopyableChip } from "@/ui-kit/copyable-chip"; +import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { ReviewEditSection } from "../../../modals"; +import { useReviewDetailsStyles } from "../review-details-card.styles"; + +interface ReviewSubjectSectionProps { + review: ApiReview; + onEdit: (section: ReviewEditSection) => void; + onOpenProduct: () => void; +} + +export function ReviewSubjectSection({ review, onEdit, onOpenProduct }: ReviewSubjectSectionProps) { + const { styles } = useReviewDetailsStyles(); + return ( + + onEdit("subject")} label="Edit product & purchase" testId="review-subject-actions" />} + /> + + +
    + + {review.product.title} + + Variant: {review.variant?.title ?? "No variant selected"} + + +
    + +
    +
    + + Order ID + {review.orderId ? : } + + + Order line ID + {review.orderLineId ? : } + +
    + + Purchase evidence is linked to this review but is not displayed publicly. + +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/graphql/details-fragment.ts b/admin/src/domains/customer-content/reviews/graphql/details-fragment.ts new file mode 100644 index 000000000..e2b99c1af --- /dev/null +++ b/admin/src/domains/customer-content/reviews/graphql/details-fragment.ts @@ -0,0 +1,58 @@ +import { gql } from "@apollo/client"; +import { FILE_FRAGMENT } from "@/domains/media/graphql"; + +export const REVIEW_DETAILS_FRAGMENT = gql` + fragment ReviewDetailsFields on Review { + id revision title body locale status + sourceChannel sourceMetadata idempotencyKey + moderationNote moderatedByPrincipalId moderatedAt + createdAt updatedAt + author { + type principalId displayName email + customer { id displayName email preferredLocale } + } + product { id title } + variant { id title } + orderId orderLineId rating verificationStatus verificationMethod verifiedAt + isVerifiedPurchase isIncentivized incentiveDisclosure + ratings { + value createdAt updatedAt + criterion { id code defaultTitle weight } + } + metrics { + likeCount dislikeCount reportCount openReportCount mediaCount + childCount officialChildCount acceptedChildCount lastChildAt updatedAt + } + media { + id sortIndex caption status moderationNote moderatedByPrincipalId moderatedAt createdAt updatedAt + file { ...FileFields } + } + replies(first: 100, orderBy: [{ field: sortIndex, direction: asc }]) { + edges { node { + id revision body locale status moderationNote isOfficial sortIndex createdAt updatedAt + author { type principalId displayName email customer { id displayName email } } + metrics { likeCount dislikeCount reportCount openReportCount } + } } + totalCount + } + votes(first: 100) { + edges { node { id type createdAt updatedAt voterCustomer { id displayName email } } } + totalCount + } + reports(first: 100) { + edges { node { + id reason details status assignedToPrincipalId resolutionNote resolvedByPrincipalId resolvedAt createdAt updatedAt + reporterCustomer { id displayName email } + } } + totalCount + } + externalReferences(first: 100) { + edges { node { + id externalSystem externalType externalId externalUrl direction syncStatus etag contentChecksum + lastSyncedAt lastError metadata createdAt updatedAt deletedAt + } } + totalCount + } + } + ${FILE_FRAGMENT} +`; diff --git a/admin/src/domains/customer-content/reviews/graphql/fragments.ts b/admin/src/domains/customer-content/reviews/graphql/fragments.ts new file mode 100644 index 000000000..559bf9eb5 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/graphql/fragments.ts @@ -0,0 +1,84 @@ +import { gql } from "@apollo/client"; +import { FILE_FRAGMENT } from "@/domains/media/graphql"; + +export const REVIEW_LIST_FRAGMENT = gql` + fragment ReviewListFields on Review { + id + title + body + rating + status + isVerifiedPurchase + verificationStatus + createdAt + updatedAt + product { + id + title + } + author { + type + displayName + email + customer { + id + } + } + metrics { + likeCount + dislikeCount + reportCount + openReportCount + mediaCount + } + } +`; + +export const REVIEW_EDITOR_FRAGMENT = gql` + fragment ReviewEditorFields on Review { + ...ReviewListFields + revision + locale + moderationNote + moderatedAt + media { + id + sortIndex + caption + status + file { + ...FileFields + } + } + reports(first: 50) { + edges { + node { + id + reason + details + status + createdAt + reporterCustomer { + id + displayName + email + } + } + } + totalCount + } + author { + type + displayName + email + customer { + id + displayName + email + preferredLocale + } + } + } + ${REVIEW_LIST_FRAGMENT} + ${FILE_FRAGMENT} +`; diff --git a/admin/src/domains/customer-content/reviews/graphql/index.ts b/admin/src/domains/customer-content/reviews/graphql/index.ts new file mode 100644 index 000000000..a84c23674 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/graphql/index.ts @@ -0,0 +1,4 @@ +export * from "./fragments"; +export * from "./details-fragment"; +export * from "./queries"; +export * from "./mutations"; diff --git a/admin/src/domains/customer-content/reviews/graphql/mutations.ts b/admin/src/domains/customer-content/reviews/graphql/mutations.ts new file mode 100644 index 000000000..1b11b33e9 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/graphql/mutations.ts @@ -0,0 +1,53 @@ +import { gql } from "@apollo/client"; +import { REVIEW_EDITOR_FRAGMENT } from "./fragments"; + +export const REVIEW_CREATE_MUTATION = gql` + mutation ReviewCreate($input: ReviewCreateInput!) { + reviewsMutation { + reviewCreate(input: $input) { + review { + ...ReviewEditorFields + } + userErrors { + code + field + message + } + } + } + } + ${REVIEW_EDITOR_FRAGMENT} +`; + +export const REVIEW_UPDATE_MUTATION = gql` + mutation ReviewUpdate( + $reviewId: ID! + $expectedRevision: Int! + $operations: ReviewUpdateInput + ) { + reviewsMutation { + reviewUpdate( + reviewId: $reviewId + expectedRevision: $expectedRevision + operations: $operations + ) { + review { + ...ReviewEditorFields + } + operationResults { + errors { + code + field + message + } + } + userErrors { + code + field + message + } + } + } + } + ${REVIEW_EDITOR_FRAGMENT} +`; diff --git a/admin/src/domains/customer-content/reviews/graphql/operation-types.ts b/admin/src/domains/customer-content/reviews/graphql/operation-types.ts new file mode 100644 index 000000000..1b71dd61d --- /dev/null +++ b/admin/src/domains/customer-content/reviews/graphql/operation-types.ts @@ -0,0 +1,81 @@ +import type { + ApiCatalogQuery, + ApiCustomerConnection, + ApiCustomersQuery, + ApiProductConnection, + ApiProductReviewsWidget, + ApiReview, + ApiReviewConnection, + ApiReviewCreateInput, + ApiReviewCreatePayload, + ApiReviewOrderByInput, + ApiReviewsMutation, + ApiReviewsQuery, + ApiReviewUpdateInput, + ApiReviewUpdatePayload, + ApiReviewWhereInput, + ApiWidgetQuery, +} from "@/graphql/types"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; + +export interface ReviewsQueryData { + reviewsQuery: Pick & { + reviews: ApiReviewConnection; + }; +} + +export interface ReviewsQueryVariables extends RelayCursorPaginationVariables { + where?: ApiReviewWhereInput | null; + orderBy?: ApiReviewOrderByInput[] | null; +} + +export interface ReviewQueryData { + reviewsQuery: Pick & { + review: ApiReview | null; + }; +} + +export interface ReviewQueryVariables { + id: string; +} + +export interface ReviewEditorContextQueryData { + catalogQuery: Pick & { + products: ApiProductConnection; + }; + customersQuery: Pick & { + customers: ApiCustomerConnection; + }; +} + +export interface ReviewCreateMutationData { + reviewsMutation: Pick & { + reviewCreate: ApiReviewCreatePayload; + }; +} + +export interface ReviewCreateMutationVariables { + input: ApiReviewCreateInput; +} + +export interface ReviewUpdateMutationData { + reviewsMutation: Pick & { + reviewUpdate: ApiReviewUpdatePayload; + }; +} + +export interface ReviewUpdateMutationVariables { + reviewId: string; + expectedRevision: number; + operations: ApiReviewUpdateInput; +} + +export interface ProductReviewsWidgetQueryData { + widgetQuery: Pick & { + reviews: ApiProductReviewsWidget; + }; +} + +export interface ProductReviewsWidgetQueryVariables { + productId: string; +} diff --git a/admin/src/domains/customer-content/reviews/graphql/queries.ts b/admin/src/domains/customer-content/reviews/graphql/queries.ts new file mode 100644 index 000000000..db8abdece --- /dev/null +++ b/admin/src/domains/customer-content/reviews/graphql/queries.ts @@ -0,0 +1,120 @@ +import { gql } from "@apollo/client"; +import { REVIEW_LIST_FRAGMENT } from "./fragments"; +import { REVIEW_DETAILS_FRAGMENT } from "./details-fragment"; + +export const REVIEWS_QUERY = gql` + query Reviews( + $first: Int + $after: String + $last: Int + $before: String + $where: ReviewWhereInput + $orderBy: [ReviewOrderByInput!] + ) { + reviewsQuery { + reviews( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...ReviewListFields + } + } + pageInfo { + startCursor + endCursor + hasPreviousPage + hasNextPage + } + totalCount + } + } + } + ${REVIEW_LIST_FRAGMENT} +`; + +export const REVIEW_QUERY = gql` + query Review($id: ID!) { + reviewsQuery { + review(id: $id) { + ...ReviewDetailsFields + } + } + } + ${REVIEW_DETAILS_FRAGMENT} +`; + +export const REVIEW_EDITOR_CONTEXT_QUERY = gql` + query ReviewEditorContext { + catalogQuery { + products(first: 250) { + edges { + node { + id + title + } + } + } + } + customersQuery { + customers(first: 250) { + edges { + node { + id + displayName + email + preferredLocale + } + } + } + } + } +`; + +export const PRODUCT_REVIEWS_WIDGET_QUERY = gql` + query ProductReviewsWidget($productId: ID!) { + widgetQuery { + reviews(productId: $productId) { + reviewSummary { + reviewCount + verifiedReviewCount + mediaReviewCount + averageRating + ratingBreakdown { + rating1Count + rating2Count + rating3Count + rating4Count + rating5Count + } + criteria { + criterion { + id + defaultTitle + } + reviewCount + averageRating + } + lastReviewedAt + updatedAt + } + questionSummary { + questionCount + answeredQuestionCount + unansweredQuestionCount + answerCount + officialAnswerCount + lastQuestionAt + lastAnsweredAt + updatedAt + } + } + } + } +`; diff --git a/admin/src/domains/customer-content/reviews/hooks/index.ts b/admin/src/domains/customer-content/reviews/hooks/index.ts new file mode 100644 index 000000000..18d1ad196 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/hooks/index.ts @@ -0,0 +1,7 @@ +export { useReviews } from "./use-reviews"; +export type { UseReviewsReturn } from "./use-reviews"; +export { useReview } from "./use-review"; +export { useReviewEditorContext } from "./use-review-editor-context"; +export { useCreateReview } from "./use-create-review"; +export { useUpdateReview } from "./use-update-review"; +export { useProductReviewsWidget } from "./use-product-reviews-widget"; diff --git a/admin/src/domains/customer-content/reviews/hooks/use-create-review.ts b/admin/src/domains/customer-content/reviews/hooks/use-create-review.ts new file mode 100644 index 000000000..785667c42 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/hooks/use-create-review.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiGenericUserError, ApiReview, ApiReviewCreateInput } from "@/graphql/types"; +import { REVIEW_CREATE_MUTATION, REVIEWS_QUERY } from "../graphql"; +import type { + ReviewCreateMutationData, + ReviewCreateMutationVariables, +} from "../graphql/operation-types"; + +export function useCreateReview() { + const [mutate, { loading, error, reset }] = useMutation< + ReviewCreateMutationData, + ReviewCreateMutationVariables + >(REVIEW_CREATE_MUTATION); + + const createReview = useCallback(async (input: ApiReviewCreateInput): Promise<{ + review: ApiReview | null; + userErrors: ApiGenericUserError[]; + }> => { + try { + const result = await mutate({ + variables: { input }, + refetchQueries: [REVIEWS_QUERY], + }); + const payload = result.data?.reviewsMutation.reviewCreate; + return { + review: payload?.review ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Unable to create review"; + return { + review: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] as ApiGenericUserError[], + }; + } + }, [mutate]); + + return { createReview, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/customer-content/reviews/hooks/use-product-reviews-widget.ts b/admin/src/domains/customer-content/reviews/hooks/use-product-reviews-widget.ts new file mode 100644 index 000000000..8c87e3c12 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/hooks/use-product-reviews-widget.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { skipToken, useQuery } from "@apollo/client/react"; +import type { ApiProductReviewsWidget } from "@/graphql/types"; +import { PRODUCT_REVIEWS_WIDGET_QUERY } from "../graphql"; +import type { + ProductReviewsWidgetQueryData, + ProductReviewsWidgetQueryVariables, +} from "../graphql/operation-types"; + +export function useProductReviewsWidget(productId?: string) { + const queryOptions = useMemo( + () => + productId + ? { + variables: { productId }, + fetchPolicy: "cache-and-network" as const, + } + : skipToken, + [productId], + ); + + const { data, previousData, loading, error, refetch } = useQuery< + ProductReviewsWidgetQueryData, + ProductReviewsWidgetQueryVariables + >(PRODUCT_REVIEWS_WIDGET_QUERY, queryOptions); + + const refresh = useCallback(async (): Promise => { + if (!productId) return null; + const result = await refetch({ productId }); + return result.data?.widgetQuery.reviews ?? null; + }, [productId, refetch]); + + const effectiveData = data ?? previousData; + + return useMemo( + () => ({ + data: effectiveData?.widgetQuery.reviews ?? null, + loading, + error: error ?? null, + refetch: refresh, + }), + [effectiveData, error, loading, refresh], + ); +} diff --git a/admin/src/domains/customer-content/reviews/hooks/use-review-editor-context.ts b/admin/src/domains/customer-content/reviews/hooks/use-review-editor-context.ts new file mode 100644 index 000000000..b40deec4d --- /dev/null +++ b/admin/src/domains/customer-content/reviews/hooks/use-review-editor-context.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { REVIEW_EDITOR_CONTEXT_QUERY } from "../graphql"; +import type { ReviewEditorContextQueryData } from "../graphql/operation-types"; + +export function useReviewEditorContext() { + const { data, previousData, loading, error, refetch } = useQuery< + ReviewEditorContextQueryData + >(REVIEW_EDITOR_CONTEXT_QUERY, { fetchPolicy: "cache-and-network" }); + const result = data ?? previousData; + + return { + context: result + ? { + products: result.catalogQuery.products.edges.map((edge) => edge.node), + customers: result.customersQuery.customers.edges.map((edge) => edge.node), + } + : null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/customer-content/reviews/hooks/use-review.ts b/admin/src/domains/customer-content/reviews/hooks/use-review.ts new file mode 100644 index 000000000..98bab7551 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/hooks/use-review.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { REVIEW_QUERY } from "../graphql"; +import type { ReviewQueryData, ReviewQueryVariables } from "../graphql/operation-types"; + +export function useReview(id?: string) { + const { data, previousData, loading, error, refetch } = useQuery< + ReviewQueryData, + ReviewQueryVariables + >(REVIEW_QUERY, { + variables: { id: id ?? "" }, + skip: !id, + fetchPolicy: "cache-and-network", + }); + + return { + review: (data ?? previousData)?.reviewsQuery.review ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/customer-content/reviews/hooks/use-reviews.ts b/admin/src/domains/customer-content/reviews/hooks/use-reviews.ts new file mode 100644 index 000000000..e3313eef4 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/hooks/use-reviews.ts @@ -0,0 +1,47 @@ +"use client"; + +import type { + ApiPageInfo, + ApiReview, + ApiReviewConnection, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import { REVIEWS_QUERY } from "../graphql"; +import type { + ReviewsQueryData, + ReviewsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseReviewsReturn { + reviews: ApiReview[]; + connection: ApiReviewConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useReviews(variables: ReviewsQueryVariables): UseReviewsReturn { + const result = useRelayConnectionQuery< + ReviewsQueryData, + ReviewsQueryVariables, + ApiReview, + ApiReviewConnection + >({ + query: REVIEWS_QUERY, + variables, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.reviewsQuery.reviews, + }); + + return { + reviews: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/admin/src/domains/customer-content/reviews/hooks/use-update-review.ts b/admin/src/domains/customer-content/reviews/hooks/use-update-review.ts new file mode 100644 index 000000000..ab623db3b --- /dev/null +++ b/admin/src/domains/customer-content/reviews/hooks/use-update-review.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiGenericUserError, ApiReview, ApiReviewUpdateInput } from "@/graphql/types"; +import { REVIEW_UPDATE_MUTATION, REVIEWS_QUERY } from "../graphql"; +import type { + ReviewUpdateMutationData, + ReviewUpdateMutationVariables, +} from "../graphql/operation-types"; + +export function useUpdateReview() { + const [mutate, { loading, error, reset }] = useMutation< + ReviewUpdateMutationData, + ReviewUpdateMutationVariables + >(REVIEW_UPDATE_MUTATION); + + const updateReview = useCallback(async ( + reviewId: string, + expectedRevision: number, + operations: ApiReviewUpdateInput, + ): Promise<{ + review: ApiReview | null; + userErrors: ApiGenericUserError[]; + }> => { + try { + const result = await mutate({ + variables: { reviewId, expectedRevision, operations }, + refetchQueries: [REVIEWS_QUERY], + }); + const payload = result.data?.reviewsMutation.reviewUpdate; + const operationErrors = payload?.operationResults.flatMap((item) => item.errors) ?? []; + return { + review: payload?.review ?? null, + userErrors: [...(payload?.userErrors ?? []), ...operationErrors], + }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Unable to update review"; + return { + review: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] as ApiGenericUserError[], + }; + } + }, [mutate]); + + return { updateReview, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/customer-content/reviews/mappers/index.ts b/admin/src/domains/customer-content/reviews/mappers/index.ts new file mode 100644 index 000000000..42440f758 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/mappers/index.ts @@ -0,0 +1 @@ +export * from "./review-form.mapper"; diff --git a/admin/src/domains/customer-content/reviews/mappers/review-form.mapper.ts b/admin/src/domains/customer-content/reviews/mappers/review-form.mapper.ts new file mode 100644 index 000000000..7795c94ad --- /dev/null +++ b/admin/src/domains/customer-content/reviews/mappers/review-form.mapper.ts @@ -0,0 +1,127 @@ +import type { FieldPath } from "react-hook-form"; +import type { + ApiGenericUserError, + ApiReview, + ApiReviewContentAuthorCreateInput, + ApiReviewContentAuthorUpdateInput, + ApiReviewCreateInput, + ApiReviewMediaSyncItemInput, + ApiReviewUpdateInput, +} from "@/graphql/types"; +import { ReviewContentAuthorType } from "@/graphql/types"; +import type { ReviewFormValues } from "../modals/review-modal/schema"; + +function buildAuthor(values: ReviewFormValues): ApiReviewContentAuthorCreateInput { + return { + type: values.authorType, + customerId: values.authorType === ReviewContentAuthorType.Customer + ? values.customerId || null + : null, + displayName: values.authorDisplayName.trim(), + email: values.authorEmail.trim() || null, + }; +} + +function buildAuthorUpdate(values: ReviewFormValues): ApiReviewContentAuthorUpdateInput { + return buildAuthor(values); +} + +function buildCreateMedia(values: ReviewFormValues): ApiReviewMediaSyncItemInput[] { + return values.media.map((file, sortIndex) => ({ fileId: file.id, sortIndex })); +} + +function buildUpdateMedia( + values: ReviewFormValues, + review: ApiReview, +): ApiReviewMediaSyncItemInput[] { + return values.media.map((file, sortIndex) => { + const current = review.media.find((item) => item.file.id === file.id); + return { + fileId: file.id, + sortIndex, + caption: current?.caption ?? null, + moderation: current + ? { + status: current.status, + moderationNote: current.moderationNote ?? null, + } + : undefined, + }; + }); +} + +export function buildReviewCreateInput(values: ReviewFormValues): ApiReviewCreateInput { + return { + content: { + title: values.title.trim() || null, + body: values.body.trim(), + locale: values.locale.trim(), + author: buildAuthor(values), + source: { channel: "ADMIN" }, + status: values.status, + moderationNote: values.moderationNote.trim() || null, + }, + productId: values.productId, + rating: values.rating, + verification: { status: values.verificationStatus }, + media: buildCreateMedia(values), + }; +} + +export function buildReviewUpdateInput( + values: ReviewFormValues, + review: ApiReview, +): ApiReviewUpdateInput { + return { + content: { + text: { + title: values.title.trim() || null, + body: values.body.trim(), + locale: values.locale.trim(), + }, + author: buildAuthorUpdate(values), + moderation: { + status: values.status, + moderationNote: values.moderationNote.trim() || null, + }, + }, + subject: { productId: values.productId }, + rating: { overall: values.rating }, + verification: { status: values.verificationStatus }, + media: buildUpdateMedia(values, review), + }; +} + +const fieldMap: Record> = { + "content.author.customerId": "customerId", + "content.author.type": "authorType", + "content.author.displayName": "authorDisplayName", + "content.author.email": "authorEmail", + "content.text.title": "title", + "content.text.body": "body", + "content.text.locale": "locale", + "content.moderation.status": "status", + "content.moderation.moderationNote": "moderationNote", + "content.title": "title", + "content.body": "body", + "content.locale": "locale", + "content.status": "status", + "content.moderationNote": "moderationNote", + productId: "productId", + "subject.productId": "productId", + rating: "rating", + "rating.overall": "rating", + verification: "verificationStatus", + "verification.status": "verificationStatus", + media: "media", +}; + +export function mapReviewUserErrors(errors: ApiGenericUserError[]) { + return errors.map((error) => { + const path = error.field?.join(".") ?? ""; + const field = Object.entries(fieldMap).find( + ([apiPath]) => path === apiPath || path.endsWith(`.${apiPath}`), + )?.[1] ?? null; + return { field, message: error.message }; + }); +} diff --git a/admin/src/domains/customer-content/reviews/modals.ts b/admin/src/domains/customer-content/reviews/modals.ts new file mode 100644 index 000000000..5d435e64b --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals.ts @@ -0,0 +1,105 @@ +"use client"; + +import { useCallback } from "react"; +import { createModalStackHook, useModalStack } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; +import type { ApiFile } from "@/graphql/types"; +import { ReviewContentStatus } from "@/graphql/types"; + +export const REVIEW_MODAL_TYPE = "customer-review"; +export const REVIEW_CREATE_MODAL_TYPE = "customer-review-create"; +export const REVIEW_EDIT_CONTENT_MODAL_TYPE = "customer-review-edit-content"; +export const REVIEW_EDIT_REVIEWER_MODAL_TYPE = "customer-review-edit-reviewer"; +export const REVIEW_EDIT_SUBJECT_MODAL_TYPE = "customer-review-edit-subject"; +export const REVIEW_EDIT_RATINGS_MODAL_TYPE = "customer-review-edit-ratings"; +export const REVIEW_EDIT_MODERATION_MODAL_TYPE = "customer-review-edit-moderation"; +export const REVIEW_EDIT_VERIFICATION_MODAL_TYPE = "customer-review-edit-verification"; +export const REVIEW_EDIT_INCENTIVE_MODAL_TYPE = "customer-review-edit-incentive"; +export const REVIEW_EDIT_MEDIA_MODAL_TYPE = "customer-review-edit-media"; +export const REVIEW_EDIT_MEDIA_ITEM_MODAL_TYPE = "customer-review-edit-media-item"; +export const REVIEW_TECHNICAL_METADATA_MODAL_TYPE = "customer-review-technical-metadata"; + +export interface ReviewModalPayload extends IModalStackPayload { + entityId: string; + onSaved?: () => Promise | unknown; +} + +export interface ReviewCreateModalPayload extends IModalStackPayload { + onSaved?: () => Promise | unknown; +} + +export type ReviewEditSection = + | "content" + | "reviewer" + | "subject" + | "ratings" + | "moderation" + | "verification" + | "incentive" + | "media"; + +export interface ReviewEditModalPayload extends ReviewModalPayload { + section: ReviewEditSection; + initialMediaFileId?: string; +} + +export interface ReviewSectionModalPayload extends ReviewModalPayload { + initialMediaFileId?: string; +} + +export interface ReviewMediaDraftItem { + file: ApiFile; + caption: string | null; + status: ReviewContentStatus; + moderationNote: string | null; + moderatedByPrincipalId: string | null; + moderatedAt: string | null; + moderationDirty: boolean; +} + +export interface ReviewMediaItemModalPayload extends IModalStackPayload { + item: ReviewMediaDraftItem; + onApply: (item: ReviewMediaDraftItem) => void; +} + +const editModalTypeBySection: Record = { + content: REVIEW_EDIT_CONTENT_MODAL_TYPE, + reviewer: REVIEW_EDIT_REVIEWER_MODAL_TYPE, + subject: REVIEW_EDIT_SUBJECT_MODAL_TYPE, + ratings: REVIEW_EDIT_RATINGS_MODAL_TYPE, + moderation: REVIEW_EDIT_MODERATION_MODAL_TYPE, + verification: REVIEW_EDIT_VERIFICATION_MODAL_TYPE, + incentive: REVIEW_EDIT_INCENTIVE_MODAL_TYPE, + media: REVIEW_EDIT_MEDIA_MODAL_TYPE, +}; + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [REVIEW_MODAL_TYPE]: ReviewModalPayload; + [REVIEW_CREATE_MODAL_TYPE]: ReviewCreateModalPayload; + [REVIEW_EDIT_CONTENT_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_REVIEWER_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_SUBJECT_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_RATINGS_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_MODERATION_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_VERIFICATION_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_INCENTIVE_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_MEDIA_MODAL_TYPE]: ReviewSectionModalPayload; + [REVIEW_EDIT_MEDIA_ITEM_MODAL_TYPE]: ReviewMediaItemModalPayload; + [REVIEW_TECHNICAL_METADATA_MODAL_TYPE]: ReviewModalPayload; + } +} + +export const useReviewModal = createModalStackHook(REVIEW_MODAL_TYPE); +export const useReviewCreateModal = createModalStackHook(REVIEW_CREATE_MODAL_TYPE); +export const useReviewMediaItemModal = createModalStackHook(REVIEW_EDIT_MEDIA_ITEM_MODAL_TYPE); +export const useReviewTechnicalMetadataModal = createModalStackHook(REVIEW_TECHNICAL_METADATA_MODAL_TYPE); + +export function useReviewEditModal() { + const { push: pushToStack } = useModalStack(); + const push = useCallback((payload: ReviewEditModalPayload) => { + const { section, ...modalPayload } = payload; + return pushToStack(editModalTypeBySection[section], modalPayload); + }, [pushToStack]); + return { push }; +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-content-modal/edit-review-content-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-content-modal/edit-review-content-modal.tsx new file mode 100644 index 000000000..7b1c17409 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-content-modal/edit-review-content-modal.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Flex, Input, Select } from "antd"; +import { shopLocales } from "@/defs/localization"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + reviewContentSectionSchema, + type ReviewContentSectionValues, +} from "../review-modal/schema"; +import { ReviewFormField, ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +export function EditReviewContentModal() { + const state = useReviewSectionModal("Review content updated"); + const initialized = useRef(false); + const lastReload = useRef(-1); + const form = useForm({ + resolver: zodResolver(reviewContentSectionSchema), + defaultValues: { locale: "en", title: "", body: "" }, + mode: "onChange", + }); + const { control, handleSubmit, reset, setError, formState: { errors, isDirty, isValid } } = form; + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + reset({ locale: state.review.locale, title: state.review.title ?? "", body: state.review.body }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.reloadVersion, state.review]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const submit = handleSubmit(async (values) => { + await state.save( + { content: { text: { locale: values.locale, title: values.title.trim() || null, body: values.body.trim() } } }, + { + "content.text.locale": "locale", + "content.text.title": "title", + "content.text.body": "body", + }, + setError, + ); + }); + + return ( + void submit()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + + + + + ( + + )} /> + + + ( + + )} /> + + + + ) : null} + + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-content-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-content-modal/index.ts new file mode 100644 index 000000000..47937a4ec --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-content-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewContentModal } from "./edit-review-content-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-incentive-modal/edit-review-incentive-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-incentive-modal/edit-review-incentive-modal.tsx new file mode 100644 index 000000000..2df39a705 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-incentive-modal/edit-review-incentive-modal.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Flex, Input, Switch, Typography } from "antd"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + reviewIncentiveSectionSchema, + type ReviewIncentiveSectionValues, +} from "../review-modal/schema"; +import { ReviewFormField, ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +export function EditReviewIncentiveModal() { + const state = useReviewSectionModal("Incentive disclosure updated"); + const initialized = useRef(false); + const lastReload = useRef(-1); + const form = useForm({ + resolver: zodResolver(reviewIncentiveSectionSchema), + defaultValues: { isIncentivized: false, disclosure: "" }, + mode: "onChange", + }); + const { control, handleSubmit, reset, setError, watch, formState: { errors, isDirty, isValid } } = form; + const isIncentivized = watch("isIncentivized"); + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + reset({ isIncentivized: state.review.isIncentivized, disclosure: state.review.incentiveDisclosure ?? "" }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.reloadVersion, state.review]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const submit = handleSubmit(async (values) => { + await state.save( + { incentive: { isIncentivized: values.isIncentivized, disclosure: values.isIncentivized ? values.disclosure.trim() : null } }, + { "incentive.isIncentivized": "isIncentivized", "incentive.disclosure": "disclosure" }, + setError, + ); + }); + + return ( + void submit()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + + + + ( + + )} /> + {isIncentivized ? ( + + ( + + )} /> + + ) : ( + No disclosure will be shown and any existing disclosure will be cleared. + )} + + + ) : null} + + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-incentive-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-incentive-modal/index.ts new file mode 100644 index 000000000..d64b4f0c4 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-incentive-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewIncentiveModal } from "./edit-review-incentive-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-media-item-modal/edit-review-media-item-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-media-item-modal/edit-review-media-item-modal.tsx new file mode 100644 index 000000000..b2be43260 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-media-item-modal/edit-review-media-item-modal.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Flex, Input, Segmented, Typography } from "antd"; +import { + LuCircleCheck as CheckCircleOutlined, + LuInfo as InfoCircleOutlined, + LuClock as ClockCircleOutlined, + LuCircleX as CloseCircleOutlined, +} from "react-icons/lu"; +import { ReviewContentStatus } from "@/graphql/types"; +import { useCurrentUser } from "@/domains/auth"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { ReviewMediaDraftItem, ReviewMediaItemModalPayload } from "../../modals"; +import { formatReviewDateTime, humanizeEnum } from "../../components/review-details-card/review-details-card.utils"; +import { reviewMediaItemSchema, type ReviewMediaItemValues } from "../review-modal/schema"; +import { useReviewFormStyles } from "../shared/review-form.styles"; +import { ReviewFormField } from "../shared/review-section-modal"; + +const consequence: Record = { + [ReviewContentStatus.Pending]: "Awaiting moderation and excluded from published review media.", + [ReviewContentStatus.Published]: "Included in published review media.", + [ReviewContentStatus.Rejected]: "Excluded from published review media.", +}; + +function formatFileSize(bytes: number | string) { + const value = Number(bytes) || 0; + if (!value) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + return `${(value / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`; +} + +export function EditReviewMediaItemModal() { + const { styles, cx } = useReviewFormStyles(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as ReviewMediaItemModalPayload; + const currentUser = useCurrentUser(); + const form = useForm({ + resolver: zodResolver(reviewMediaItemSchema), + defaultValues: { + caption: value.item.caption ?? "", + status: value.item.status, + moderationNote: value.item.moderationNote ?? "", + }, + mode: "onChange", + }); + const { control, handleSubmit, watch, formState: { errors, isDirty, isValid } } = form; + const status = watch("status"); + useEffect(() => setDirty(isDirty), [isDirty, setDirty]); + const contextClass = status === ReviewContentStatus.Pending + ? styles.contextPending + : status === ReviewContentStatus.Published + ? styles.contextPublished + : styles.contextRejected; + + const apply = handleSubmit((values) => { + const moderationChanged = values.status !== value.item.status + || values.moderationNote.trim() !== (value.item.moderationNote ?? ""); + const now = new Date().toISOString(); + const next: ReviewMediaDraftItem = { + ...value.item, + caption: values.caption.trim() || null, + status: values.status, + moderationNote: values.moderationNote.trim() || null, + moderationDirty: value.item.moderationDirty || moderationChanged, + moderatedByPrincipalId: moderationChanged + ? values.status === ReviewContentStatus.Pending ? null : currentUser.user?.id ?? null + : value.item.moderatedByPrincipalId, + moderatedAt: moderationChanged + ? values.status === ReviewContentStatus.Pending ? null : now + : value.item.moderatedAt, + }; + value.onApply(next); + setDirty(false); + forcePop(); + }); + const file = value.item.file; + const isVideo = file.mimeType?.startsWith("video/") ?? false; + + return ( + void apply() }} + /> + )} + > +
    + + + {isVideo ? ( + + + + + + } /> + + + ( + }, + { value: ReviewContentStatus.Published, label: "Published", icon: }, + { value: ReviewContentStatus.Rejected, label: "Rejected", icon: }, + ]} + /> + )} /> + +
    + {humanizeEnum(status)}
    + {consequence[status]} +
    + + } /> + + {value.item.moderatedAt ? ( + + Moderated {formatReviewDateTime(value.item.moderatedAt)}{value.item.moderatedByPrincipalId ? ` by ${value.item.moderatedByPrincipalId}` : ""} + + ) : null} +
    +
    +
    + + + Apply updates the draft. Save the media gallery to persist changes. + +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-media-item-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-media-item-modal/index.ts new file mode 100644 index 000000000..116569899 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-media-item-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewMediaItemModal } from "./edit-review-media-item-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-media-modal/edit-review-media-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-media-modal/edit-review-media-modal.tsx new file mode 100644 index 000000000..25929fa13 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-media-modal/edit-review-media-modal.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Flex, Tag, Typography, type MenuProps } from "antd"; +import { LuArrowDown as ArrowDownOutlined, LuArrowUp as ArrowUpOutlined } from "react-icons/lu"; +import type { ApiFile, ApiGenericUserError } from "@/graphql/types"; +import { ReviewContentStatus } from "@/graphql/types"; +import { EntityMediaGallery } from "@/domains/media/components/entity-media-gallery"; +import { useReviewConfiguration } from "@/domains/customer-content/management/hooks"; +import { humanizeEnum, reviewStatusConfig } from "../../components/review-details-card/review-details-card.utils"; +import type { ReviewMediaDraftItem } from "../../modals"; +import { useReviewMediaItemModal } from "../../modals"; +import { useReviewFormStyles } from "../shared/review-form.styles"; +import { ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +function draftFromReview(review: NonNullable["review"]>): ReviewMediaDraftItem[] { + return review.media.map((item) => ({ + file: item.file, + caption: item.caption ?? null, + status: item.status, + moderationNote: item.moderationNote ?? null, + moderatedByPrincipalId: item.moderatedByPrincipalId ?? null, + moderatedAt: item.moderatedAt ?? null, + moderationDirty: false, + })); +} + +export function EditReviewMediaModal() { + const { styles } = useReviewFormStyles(); + const state = useReviewSectionModal("Customer media updated"); + const configuration = useReviewConfiguration(); + const { push: openItem } = useReviewMediaItemModal(); + const initialized = useRef(false); + const lastReload = useRef(-1); + const initialItemOpened = useRef(false); + const [items, setItems] = useState([]); + const [dirty, setDirty] = useState(false); + const [itemErrors, setItemErrors] = useState>({}); + const { setError } = useForm<{ media: string }>({ defaultValues: { media: "" } }); + const maxFiles = configuration.data?.reviewsQuery.storeConfiguration?.maxReviewMediaCount; + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + setItems(draftFromReview(state.review)); + setDirty(false); + setItemErrors({}); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [state.reloadVersion, state.review]); + useEffect(() => state.setDirty(dirty), [dirty, state.setDirty]); + + const reconcileFiles = (files: ApiFile[]) => { + const current = new Map(items.map((item) => [item.file.id, item])); + setItems(files.map((file) => current.get(file.id) ?? { + file, + caption: null, + status: ReviewContentStatus.Pending, + moderationNote: null, + moderatedByPrincipalId: null, + moderatedAt: null, + moderationDirty: false, + })); + setDirty(true); + }; + + const moveItem = (fromIndex: number, toIndex: number) => { + setItems((current) => { + if (toIndex < 0 || toIndex >= current.length || fromIndex === toIndex) return current; + const next = [...current]; + const [moved] = next.splice(fromIndex, 1); + if (!moved) return current; + next.splice(toIndex, 0, moved); + return next; + }); + setDirty(true); + }; + + const editItem = (file: ApiFile) => { + const item = items.find((candidate) => candidate.file.id === file.id); + if (!item) return; + openItem({ + item, + onApply: (next: ReviewMediaDraftItem) => { + setItems((current) => current.map((candidate) => candidate.file.id === next.file.id ? next : candidate)); + setItemErrors((current) => { + const nextErrors = { ...current }; + delete nextErrors[next.file.id]; + return nextErrors; + }); + setDirty(true); + }, + }); + }; + + useEffect(() => { + if (initialItemOpened.current || !state.payload.initialMediaFileId || !items.length) return; + const item = items.find((candidate) => candidate.file.id === state.payload.initialMediaFileId); + if (!item) return; + initialItemOpened.current = true; + openItem({ + item, + onApply: (next: ReviewMediaDraftItem) => { + setItems((current) => current.map((candidate) => candidate.file.id === next.file.id ? next : candidate)); + setDirty(true); + }, + }); + }, [items, openItem, state.payload.initialMediaFileId]); + + const collectItemErrors = (errors: ApiGenericUserError[]) => { + const next: Record = {}; + for (const error of errors) { + const path = error.field ?? []; + const mediaIndex = path.findIndex((part) => part === "media"); + const index = Number(path[mediaIndex + 1]); + const item = Number.isInteger(index) ? items[index] : undefined; + if (item) next[item.file.id] = error.message; + } + setItemErrors(next); + }; + + const save = async () => { + await state.save<{ media: string }>( + { + media: items.map((item, sortIndex) => ({ + fileId: item.file.id, + sortIndex, + caption: item.caption, + moderation: item.moderationDirty + ? { status: item.status, moderationNote: item.moderationNote } + : undefined, + })), + }, + {}, + setError, + collectItemErrors, + ); + }; + + return ( + maxFiles || state.conflict} + onSubmit={() => void save()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error ?? configuration.error?.message ?? null} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + item.file)} + onChange={reconcileFiles} + viewMode="list" + showUpload + showUploadButtonInHeader + showViewSwitcher={false} + allowDelete + allowSetFeatured={false} + hasFeatured={false} + accept="image/*,video/*" + maxFiles={maxFiles} + title="Customer media" + headerExtra={{items.length} / {maxFiles ?? "…"}} + emptyMessage="No customer media" + renderListMeta={(file) => { + const item = items.find((candidate) => candidate.file.id === file.id); + if (!item) return null; + const config = reviewStatusConfig[item.status]; + return ( + + + {humanizeEnum(item.status)} + + {itemErrors[file.id] ? {itemErrors[file.id]} : null} + + ); + }} + getItemMenuItems={(_file, index): NonNullable => [ + { + key: "move-up", + label: "Move up", + icon: , + disabled: index === 0, + onClick: () => moveItem(index, index - 1), + }, + { + key: "move-down", + label: "Move down", + icon: , + disabled: index === items.length - 1, + onClick: () => moveItem(index, index + 1), + }, + ]} + onEditItem={(file) => editItem(file)} + editItemLabel="Edit details" + /> + ) : null} + + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-media-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-media-modal/index.ts new file mode 100644 index 000000000..6458f9c90 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-media-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewMediaModal } from "./edit-review-media-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-moderation-modal/edit-review-moderation-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-moderation-modal/edit-review-moderation-modal.tsx new file mode 100644 index 000000000..ed1d0b9dc --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-moderation-modal/edit-review-moderation-modal.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Flex, Input, Segmented, Typography } from "antd"; +import { + LuCircleCheck as CheckCircleOutlined, + LuClock as ClockCircleOutlined, + LuCircleX as CloseCircleOutlined, +} from "react-icons/lu"; +import { ReviewContentStatus } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { formatReviewDateTime, humanizeEnum } from "../../components/review-details-card/review-details-card.utils"; +import { + reviewModerationSectionSchema, + type ReviewModerationSectionValues, +} from "../review-modal/schema"; +import { useReviewFormStyles } from "../shared/review-form.styles"; +import { ReviewFormField, ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +const consequence: Record = { + [ReviewContentStatus.Pending]: "Awaiting a moderation decision and excluded from published review surfaces.", + [ReviewContentStatus.Published]: "Visible on product pages and included in rating aggregates.", + [ReviewContentStatus.Rejected]: "Rejected and excluded from published review surfaces.", +}; + +export function EditReviewModerationModal() { + const stylesState = useReviewFormStyles(); + const { styles, cx } = stylesState; + const state = useReviewSectionModal("Review moderation updated"); + const initialized = useRef(false); + const lastReload = useRef(-1); + const form = useForm({ + resolver: zodResolver(reviewModerationSectionSchema), + defaultValues: { status: ReviewContentStatus.Pending, moderationNote: "" }, + mode: "onChange", + }); + const { control, handleSubmit, reset, setError, watch, formState: { errors, isDirty, isValid } } = form; + const status = watch("status"); + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + reset({ status: state.review.status, moderationNote: state.review.moderationNote ?? "" }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.reloadVersion, state.review]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const submit = handleSubmit(async (values) => { + await state.save( + { content: { moderation: { status: values.status, moderationNote: values.moderationNote.trim() || null } } }, + { "content.moderation.status": "status", "content.moderation.moderationNote": "moderationNote" }, + setError, + ); + }); + const contextClass = status === ReviewContentStatus.Pending + ? styles.contextPending + : status === ReviewContentStatus.Published + ? styles.contextPublished + : styles.contextRejected; + + return ( + void submit()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + <> + + + + ( + }, + { value: ReviewContentStatus.Published, label: "Published", icon: }, + { value: ReviewContentStatus.Rejected, label: "Rejected", icon: }, + ]} + /> + )} /> +
    + {humanizeEnum(status)}
    + {consequence[status]} +
    + + ( + + )} /> + +
    +
    + + Current decision: {humanizeEnum(state.review.status)} + {state.review.moderatedAt ? ` · ${formatReviewDateTime(state.review.moderatedAt)}` : ""} + {state.review.moderatedByPrincipalId ? ` · ${state.review.moderatedByPrincipalId}` : ""} + + + ) : null} +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-moderation-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-moderation-modal/index.ts new file mode 100644 index 000000000..bd7f6c11a --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-moderation-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewModerationModal } from "./edit-review-moderation-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-ratings-modal/edit-review-ratings-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-ratings-modal/edit-review-ratings-modal.tsx new file mode 100644 index 000000000..2cb2e98c8 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-ratings-modal/edit-review-ratings-modal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import type { Path } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Flex, Rate, Typography } from "antd"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + reviewRatingsSectionSchema, + type ReviewRatingsSectionValues, +} from "../review-modal/schema"; +import { ReviewFormField, ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +export function EditReviewRatingsModal() { + const state = useReviewSectionModal("Review ratings updated"); + const initialized = useRef(false); + const lastReload = useRef(-1); + const form = useForm({ + resolver: zodResolver(reviewRatingsSectionSchema), + defaultValues: { overall: 5, criteria: [] }, + mode: "onChange", + }); + const { control, handleSubmit, reset, setError, watch, formState: { errors, isDirty, isValid } } = form; + const { fields } = useFieldArray({ control, name: "criteria" }); + const overall = watch("overall"); + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + reset({ + overall: state.review.rating, + criteria: state.review.ratings.map((item) => ({ criterionId: item.criterion.id, title: item.criterion.defaultTitle, value: item.value })), + }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.reloadVersion, state.review]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const submit = handleSubmit(async (values) => { + const fieldMap: Record> = { "rating.overall": "overall" }; + values.criteria.forEach((_, index) => { + fieldMap[`rating.criteria.${index}.value`] = `criteria.${index}.value`; + fieldMap[`rating.criteria.${index}.criterionId`] = `criteria.${index}.criterionId`; + }); + await state.save( + { + rating: { + overall: values.overall, + criteria: values.criteria.map((item) => ({ criterionId: item.criterionId, value: item.value })), + }, + }, + fieldMap, + setError, + ); + }); + + return ( + void submit()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + <> + + + + + ( + + )} /> + {overall} / 5 + + + + {fields.length ? ( + + + + {fields.map((field, index) => ( + + + ( + + )} /> + {watch(`criteria.${index}.value`)} / 5 + + + ))} + Values are saved as one complete criterion rating set. + + + ) : null} + + ) : null} + + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-ratings-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-ratings-modal/index.ts new file mode 100644 index 000000000..43d33ebbc --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-ratings-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewRatingsModal } from "./edit-review-ratings-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-subject-modal/edit-review-subject-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-subject-modal/edit-review-subject-modal.tsx new file mode 100644 index 000000000..da3ee3199 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-subject-modal/edit-review-subject-modal.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, Button, Flex, Input } from "antd"; +import { LuShoppingBag as ShoppingOutlined } from "react-icons/lu"; +import { useProductPicker, useVariantPicker } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + reviewSubjectSectionSchema, + type ReviewSubjectSectionValues, +} from "../review-modal/schema"; +import { useReviewFormStyles } from "../shared/review-form.styles"; +import { ReviewFormField, ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +interface VariantPickerEntity extends IPickableEntity { + productId?: string; +} + +export function EditReviewSubjectModal() { + const { styles } = useReviewFormStyles(); + const state = useReviewSectionModal("Product and purchase updated"); + const initialized = useRef(false); + const lastReload = useRef(-1); + const [product, setProduct] = useState(null); + const [variant, setVariant] = useState(null); + const [variantNotice, setVariantNotice] = useState(false); + const form = useForm({ + resolver: zodResolver(reviewSubjectSectionSchema), + defaultValues: { productId: "", variantId: "", orderId: "", orderLineId: "" }, + mode: "onChange", + }); + const { control, handleSubmit, reset, setError, setValue, watch, formState: { errors, isDirty, isValid } } = form; + const productId = watch("productId"); + const variantId = watch("variantId"); + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + setProduct({ id: state.review.product.id, title: state.review.product.title }); + setVariant( + state.review.variant + ? { + id: state.review.variant.id, + title: state.review.variant.title ?? state.review.variant.id, + productId: state.review.product.id, + } + : null, + ); + setVariantNotice(false); + reset({ + productId: state.review.product.id, + variantId: state.review.variant?.id ?? "", + orderId: state.review.orderId ?? "", + orderLineId: state.review.orderLineId ?? "", + }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.reloadVersion, state.review]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const productPicker = useProductPicker({ + selectionMode: "single", + initialSelection: productId ? [productId] : [], + onConfirm: (items) => { + const selected = items[0] ?? null; + if (!selected) return; + if (selected.id !== productId && variantId) { + setVariant(null); + setValue("variantId", "", { shouldDirty: true, shouldValidate: true }); + setVariantNotice(true); + } + setProduct(selected); + setValue("productId", selected.id, { shouldDirty: true, shouldValidate: true }); + }, + }); + const variantPicker = useVariantPicker({ + selectionMode: "single", + initialSelection: variantId ? [variantId] : [], + queryMeta: { productId }, + onConfirm: (items) => { + const selected = (items[0] as VariantPickerEntity | undefined) ?? null; + setVariant(selected); + setVariantNotice(false); + setValue("variantId", selected?.id ?? "", { shouldDirty: true, shouldValidate: true }); + }, + }); + + const submit = handleSubmit(async (values) => { + await state.save( + { + subject: { + productId: values.productId, + variantId: values.variantId || null, + orderId: values.orderId.trim() || null, + orderLineId: values.orderLineId.trim() || null, + }, + }, + { + "subject.productId": "productId", + "subject.variantId": "variantId", + "subject.orderId": "orderId", + "subject.orderLineId": "orderLineId", + }, + setError, + ); + }); + + return ( + void submit()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + <> + + + + + + + + + + + + + + {variantId ? : null} + + + {variantNotice ? : null} + + + + +
    + + } /> + + + } /> + +
    +
    + + ) : null} +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-subject-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-subject-modal/index.ts new file mode 100644 index 000000000..589e7d62a --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-subject-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewSubjectModal } from "./edit-review-subject-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-verification-modal/edit-review-verification-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-review-verification-modal/edit-review-verification-modal.tsx new file mode 100644 index 000000000..caf134817 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-verification-modal/edit-review-verification-modal.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Flex, Input, Segmented, Typography } from "antd"; +import { ReviewVerificationStatus } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { humanizeEnum } from "../../components/review-details-card/review-details-card.utils"; +import { + reviewVerificationSectionSchema, + type ReviewVerificationSectionValues, +} from "../review-modal/schema"; +import { useReviewFormStyles } from "../shared/review-form.styles"; +import { ReviewFormField, ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +function toLocalDateTime(value?: string | null) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 16); +} + +export function EditReviewVerificationModal() { + const { styles } = useReviewFormStyles(); + const state = useReviewSectionModal("Purchase verification updated"); + const initialized = useRef(false); + const lastReload = useRef(-1); + const form = useForm({ + resolver: zodResolver(reviewVerificationSectionSchema), + defaultValues: { status: ReviewVerificationStatus.Unverified, method: "", verifiedAt: "" }, + mode: "onChange", + }); + const { control, handleSubmit, reset, setError, watch, formState: { errors, isDirty, isValid } } = form; + const status = watch("status"); + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + reset({ + status: state.review.verificationStatus, + method: state.review.verificationMethod ?? "", + verifiedAt: toLocalDateTime(state.review.verifiedAt), + }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.reloadVersion, state.review]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const submit = handleSubmit(async (values) => { + const clearsEvidence = values.status === ReviewVerificationStatus.Unverified; + await state.save( + { + verification: { + status: values.status, + method: clearsEvidence ? null : values.method.trim(), + verifiedAt: clearsEvidence ? null : new Date(values.verifiedAt).toISOString(), + }, + }, + { "verification.status": "status", "verification.method": "method", "verification.verifiedAt": "verifiedAt" }, + setError, + ); + }); + + return ( + void submit()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + + + + + ( + ({ value, label: humanizeEnum(value) }))} /> + )} /> + + {status !== ReviewVerificationStatus.Unverified ? ( +
    + + } /> + + + } /> + +
    + ) : null} + + {status === ReviewVerificationStatus.Unverified + ? "Saving as Unverified clears the verification method and timestamp." + : status === ReviewVerificationStatus.Revoked + ? "Revoked keeps the verification evidence and marks it as no longer valid." + : "Verified stores the method and timestamp as purchase evidence."} + +
    +
    + ) : null} +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-review-verification-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-review-verification-modal/index.ts new file mode 100644 index 000000000..9a290c264 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-review-verification-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewVerificationModal } from "./edit-review-verification-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/edit-reviewer-modal/edit-reviewer-modal.tsx b/admin/src/domains/customer-content/reviews/modals/edit-reviewer-modal/edit-reviewer-modal.tsx new file mode 100644 index 000000000..5490498ac --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-reviewer-modal/edit-reviewer-modal.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { App, Button, Flex, Input, Select, Typography } from "antd"; +import { LuUser as UserOutlined } from "react-icons/lu"; +import { ReviewContentAuthorType } from "@/graphql/types"; +import { useEntityPicker } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import "@/domains/customers/all-customers/picker/customer-picker-config"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { humanizeEnum } from "../../components/review-details-card/review-details-card.utils"; +import { + reviewReviewerSectionSchema, + type ReviewReviewerSectionValues, +} from "../review-modal/schema"; +import { useReviewFormStyles } from "../shared/review-form.styles"; +import { ReviewFormField, ReviewModalFrame, useReviewSectionModal } from "../shared/review-section-modal"; + +interface CustomerPickerEntity extends IPickableEntity { + email?: string; +} + +export function EditReviewerModal() { + const { modal } = App.useApp(); + const { styles } = useReviewFormStyles(); + const state = useReviewSectionModal("Reviewer updated"); + const initialized = useRef(false); + const lastReload = useRef(-1); + const [customer, setCustomer] = useState(null); + const form = useForm({ + resolver: zodResolver(reviewReviewerSectionSchema), + defaultValues: { authorType: ReviewContentAuthorType.Customer, customerId: "", displayName: "", email: "" }, + mode: "onChange", + }); + const { control, handleSubmit, reset, setError, setValue, watch, formState: { errors, isDirty, isValid } } = form; + const authorType = watch("authorType"); + const customerId = watch("customerId"); + + useEffect(() => { + if (!state.review) return; + if (initialized.current && lastReload.current === state.reloadVersion) return; + const linked = state.review.author.customer; + setCustomer(linked ? { id: linked.id, title: linked.displayName, email: linked.email ?? "" } : null); + reset({ + authorType: state.review.author.type, + customerId: linked?.id ?? "", + displayName: state.review.author.displayName, + email: state.review.author.email ?? "", + }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.reloadVersion, state.review]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const customerPicker = useEntityPicker({ + entityType: "customer", + selectionMode: "single", + initialSelection: customerId ? [customerId] : [], + onConfirm: (items) => { + const selected = items[0] ?? null; + setCustomer(selected); + setValue("customerId", selected?.id ?? "", { shouldDirty: true, shouldValidate: true }); + if (selected) { + setValue("displayName", selected.title, { shouldDirty: true, shouldValidate: true }); + setValue("email", selected.email ?? "", { shouldDirty: true, shouldValidate: true }); + } + }, + }); + + const changeAuthorType = async (next: ReviewContentAuthorType) => { + if (authorType === ReviewContentAuthorType.Customer && next !== authorType && customerId) { + const confirmed = await modal.confirm({ + title: "Remove linked customer?", + content: "The reviewer snapshot will remain editable, but the customer link will be cleared.", + okText: "Remove link", + }); + if (!confirmed) return; + setCustomer(null); + setValue("customerId", "", { shouldDirty: true, shouldValidate: true }); + } + setValue("authorType", next, { shouldDirty: true, shouldValidate: true }); + }; + + const submit = handleSubmit(async (values) => { + await state.save( + { + content: { + author: { + type: values.authorType, + customerId: values.authorType === ReviewContentAuthorType.Customer ? values.customerId : null, + displayName: values.displayName.trim(), + email: values.email.trim() || null, + }, + }, + }, + { + "content.author.type": "authorType", + "content.author.customerId": "customerId", + "content.author.displayName": "displayName", + "content.author.email": "email", + }, + setError, + ); + }); + + return ( + void submit()} + onClose={state.pop} + queryLoading={state.queryLoading} + hasReview={Boolean(state.review)} + error={state.error} + conflict={state.conflict} + onReload={() => void state.reloadLatest()} + > + {state.review ? ( + + + + + + + + + ) : null} +
    + + } /> + + + } /> + +
    + Name and email are stored as the review author snapshot. + +
    + ) : null} +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/edit-reviewer-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/edit-reviewer-modal/index.ts new file mode 100644 index 000000000..07dbdc820 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/edit-reviewer-modal/index.ts @@ -0,0 +1 @@ +export { EditReviewerModal } from "./edit-reviewer-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/review-create-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/review-create-modal/index.ts new file mode 100644 index 000000000..775eb29f8 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/review-create-modal/index.ts @@ -0,0 +1 @@ +export { ReviewCreateModal } from "./review-create-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/review-create-modal/review-create-modal.tsx b/admin/src/domains/customer-content/reviews/modals/review-create-modal/review-create-modal.tsx new file mode 100644 index 000000000..8254d00f4 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/review-create-modal/review-create-modal.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Alert, App, Button, Flex, Input, Rate, Select, Typography } from "antd"; +import { LuShoppingBag as ShoppingOutlined, LuUser as UserOutlined } from "react-icons/lu"; +import { ReviewContentAuthorType, ReviewContentStatus, ReviewVerificationStatus } from "@/graphql/types"; +import { shopLocales } from "@/defs/localization"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useEntityPicker } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import "@/shared/components/entity-picker-modal/configs/product-picker-config"; +import "@/domains/customers/all-customers/picker/customer-picker-config"; +import { useCreateReview } from "../../hooks"; +import type { ReviewCreateModalPayload } from "../../modals"; + +export function ReviewCreateModal() { + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as ReviewCreateModalPayload; + const mutation = useCreateReview(); + const [product, setProduct] = useState(null); + const [customer, setCustomer] = useState(null); + const [locale, setLocale] = useState("en"); + const [rating, setRating] = useState(5); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const dirty = !!product || !!customer || locale !== "en" || rating !== 5 || !!title || !!body; + useEffect(() => setDirty(dirty), [dirty, setDirty]); + + const productPicker = useEntityPicker({ entityType: "product", selectionMode: "single", initialSelection: product ? [product.id] : [], onConfirm: (items) => setProduct(items[0] ?? null) }); + const customerPicker = useEntityPicker({ entityType: "customer", selectionMode: "single", initialSelection: customer ? [customer.id] : [], onConfirm: (items) => setCustomer(items[0] ?? null) }); + const canSubmit = useMemo(() => !!product && !!customer && body.trim().length >= 20, [body, customer, product]); + + const submit = async () => { + if (!product || !customer) return; + setError(null); + const result = await mutation.createReview({ + productId: product.id, + rating, + content: { + title: title.trim() || null, + body: body.trim(), + locale, + author: { type: ReviewContentAuthorType.Customer, customerId: customer.id, displayName: customer.title }, + source: { channel: "ADMIN" }, + status: ReviewContentStatus.Pending, + }, + verification: { status: ReviewVerificationStatus.Unverified }, + }); + if (!result.review || result.userErrors.length) return setError(result.userErrors.map((item) => item.message).join(" ") || "Unable to create review"); + await value.onSaved?.(); setDirty(false); message.success("Review created"); forcePop(); + }; + + return }> + {mutation.error ? : null} + {error ? : null} + +
    Product *
    +
    Customer *
    +
    Locale * setTitle(event.target.value)} maxLength={150} showCount style={{ marginTop: 8 }} />
    +
    Review * setBody(event.target.value)} autoSize={{ minRows: 5, maxRows: 10 }} maxLength={5000} showCount placeholder="At least 20 characters" style={{ marginTop: 8 }} />
    +
    +
    ; +} diff --git a/admin/src/domains/customer-content/reviews/modals/review-details-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/review-details-modal/index.ts new file mode 100644 index 000000000..1b667e341 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/review-details-modal/index.ts @@ -0,0 +1 @@ +export { ReviewModal } from "./review-details-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/review-details-modal/review-details-modal.tsx b/admin/src/domains/customer-content/reviews/modals/review-details-modal/review-details-modal.tsx new file mode 100644 index 000000000..2d386c386 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/review-details-modal/review-details-modal.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Alert, App, Button, Flex, Skeleton } from "antd"; +import type { ApiReviewContentExternalReference } from "@/graphql/types"; +import { ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { useProductModal } from "@/domains/inventory/products/modals"; +import { ReviewDetailsCard } from "../../components/review-details-card"; +import { useReview } from "../../hooks"; +import { + useReviewEditModal, + useReviewTechnicalMetadataModal, + type ReviewModalPayload, +} from "../../modals"; +import { useContentActions } from "../../../shared/hooks"; +import { useExternalReferenceModal } from "../../../management/modals"; + +export function ReviewModal() { + const { message } = App.useApp(); + const { payload, pop, forcePop } = useModalStackContext(); + const value = payload as ReviewModalPayload; + const query = useReview(value.entityId); + const actions = useContentActions(); + const { push: openEdit } = useReviewEditModal(); + const { push: openTechnicalMetadata } = useReviewTechnicalMetadataModal(); + const { push: openExternalReference } = useExternalReferenceModal(); + const { push: openProduct } = useProductModal(); + const review = query.review; + + const refresh = async () => { + await query.refetch(); + await value.onSaved?.(); + }; + + const handleDelete = async () => { + if (!review) return; + const result = await actions.deleteReview({ id: review.id, expectedRevision: review.revision }); + if (!result.id || result.errors.length) throw new Error(result.errors.map((item) => item.message).join(" ") || "Unable to delete review"); + await value.onSaved?.(); + message.success("Review deleted"); + forcePop(); + }; + + const handleRedact = async () => { + if (!review) return; + const result = await actions.redact(review.id, review.revision); + if (!result.content || result.errors.length) throw new Error(result.errors.map((item) => item.message).join(" ") || "Unable to redact review"); + await refresh(); + message.success("Review redacted"); + }; + + const editExternalReference = (reference?: ApiReviewContentExternalReference) => { + if (!review) return; + openExternalReference({ + contentId: review.id, + contentLabel: review.title || review.body, + externalReference: reference, + onSaved: refresh, + }); + }; + + return ( + + {query.loading && !review ? ( + + + + + + ) : null} + {query.error ? : null} + {actions.error ? : null} + {!query.loading && !query.error && !review ? ( + Close} + /> + ) : null} + {review && !query.error ? ( + openEdit({ entityId: review.id, section, onSaved: refresh })} + onDelete={handleDelete} + onRedact={handleRedact} + onOpenTechnicalMetadata={() => openTechnicalMetadata({ entityId: review.id })} + onOpenProduct={() => openProduct({ entityId: review.product.id })} + onOpenMediaItem={(item) => openEdit({ entityId: review.id, section: "media", initialMediaFileId: item.file.id, onSaved: refresh })} + onAddExternalReference={() => editExternalReference()} + onEditExternalReference={editExternalReference} + /> + ) : null} + + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/review-modal/schema.ts b/admin/src/domains/customer-content/reviews/modals/review-modal/schema.ts new file mode 100644 index 000000000..ca7064035 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/review-modal/schema.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; +import { + ReviewContentAuthorType, + ReviewContentStatus, + ReviewVerificationStatus, + type ApiFile, +} from "@/graphql/types"; + +export const reviewFormSchema = z + .object({ + productId: z.string().min(1, "Product is required"), + authorType: z.enum(ReviewContentAuthorType), + customerId: z.string(), + authorDisplayName: z.string().trim().min(1, "Author name is required").max(150), + authorEmail: z.string().trim().email("Enter a valid email").or(z.literal("")), + locale: z.string().trim().min(2, "Locale is required").max(35), + rating: z.number().int().min(1, "Rating is required").max(5), + title: z.string().trim().max(150, "Title must be at most 150 characters"), + body: z + .string() + .trim() + .min(20, "Review must contain at least 20 characters") + .max(5000, "Review must be at most 5,000 characters"), + verificationStatus: z.enum(ReviewVerificationStatus), + status: z.enum(ReviewContentStatus), + moderationNote: z + .string() + .trim() + .max(1000, "Moderation note must be at most 1,000 characters"), + media: z + .array(z.custom()) + .max(8, "A review can contain at most 8 media files"), + }) + .superRefine((values, context) => { + if (values.authorType === ReviewContentAuthorType.Customer && !values.customerId) { + context.addIssue({ + code: "custom", + path: ["customerId"], + message: "Customer is required for a customer author", + }); + } + if (values.status === ReviewContentStatus.Rejected && !values.moderationNote) { + context.addIssue({ + code: "custom", + path: ["moderationNote"], + message: "Add a reason when rejecting a review", + }); + } + }); + +export type ReviewFormValues = z.infer; + +export const reviewContentSectionSchema = z.object({ + locale: z.string().trim().min(2, "Locale is required").max(35), + title: z.string().trim().max(150, "Title must be at most 150 characters"), + body: z.string().trim().min(20, "Review must contain at least 20 characters").max(5000, "Review must be at most 5,000 characters"), +}); + +export const reviewReviewerSectionSchema = z.object({ + authorType: z.enum(ReviewContentAuthorType), + customerId: z.string(), + displayName: z.string().trim().min(1, "Display name is required").max(150), + email: z.string().trim().email("Enter a valid email").or(z.literal("")), +}).superRefine((values, context) => { + if (values.authorType === ReviewContentAuthorType.Customer && !values.customerId) { + context.addIssue({ code: "custom", path: ["customerId"], message: "Customer is required for a customer author" }); + } +}); + +export const reviewSubjectSectionSchema = z.object({ + productId: z.string().min(1, "Product is required"), + variantId: z.string(), + orderId: z.string(), + orderLineId: z.string(), +}); + +export const reviewRatingsSectionSchema = z.object({ + overall: z.number().int().min(1).max(5), + criteria: z.array(z.object({ criterionId: z.string(), title: z.string(), value: z.number().int().min(1).max(5) })), +}); + +export const reviewModerationSectionSchema = z.object({ + status: z.enum(ReviewContentStatus), + moderationNote: z.string().trim().max(1000, "Moderation note must be at most 1,000 characters"), +}).superRefine((values, context) => { + if (values.status === ReviewContentStatus.Rejected && !values.moderationNote) { + context.addIssue({ code: "custom", path: ["moderationNote"], message: "Add a reason when rejecting a review" }); + } +}); + +export const reviewVerificationSectionSchema = z.object({ + status: z.enum(ReviewVerificationStatus), + method: z.string().trim().max(64), + verifiedAt: z.string(), +}).superRefine((values, context) => { + if (values.status !== ReviewVerificationStatus.Unverified && !values.method) { + context.addIssue({ code: "custom", path: ["method"], message: "Verification method is required" }); + } + if (values.status !== ReviewVerificationStatus.Unverified && !values.verifiedAt) { + context.addIssue({ code: "custom", path: ["verifiedAt"], message: "Verification date is required" }); + } +}); + +export const reviewIncentiveSectionSchema = z.object({ + isIncentivized: z.boolean(), + disclosure: z.string().trim().max(500, "Disclosure must be at most 500 characters"), +}).superRefine((values, context) => { + if (values.isIncentivized && !values.disclosure) { + context.addIssue({ code: "custom", path: ["disclosure"], message: "Public disclosure is required" }); + } +}); + +export const reviewMediaItemSchema = z.object({ + caption: z.string().trim().max(500, "Caption must be at most 500 characters"), + status: z.enum(ReviewContentStatus), + moderationNote: z.string().trim().max(1000, "Moderation note must be at most 1,000 characters"), +}).superRefine((values, context) => { + if (values.status === ReviewContentStatus.Rejected && !values.moderationNote) { + context.addIssue({ code: "custom", path: ["moderationNote"], message: "Moderation note is required when media is rejected" }); + } +}); + +export type ReviewContentSectionValues = z.infer; +export type ReviewReviewerSectionValues = z.infer; +export type ReviewSubjectSectionValues = z.infer; +export type ReviewRatingsSectionValues = z.infer; +export type ReviewModerationSectionValues = z.infer; +export type ReviewVerificationSectionValues = z.infer; +export type ReviewIncentiveSectionValues = z.infer; +export type ReviewMediaItemValues = z.infer; diff --git a/admin/src/domains/customer-content/reviews/modals/review-technical-metadata-modal/index.ts b/admin/src/domains/customer-content/reviews/modals/review-technical-metadata-modal/index.ts new file mode 100644 index 000000000..77ebdb0bd --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/review-technical-metadata-modal/index.ts @@ -0,0 +1 @@ +export { ReviewTechnicalMetadataModal } from "./review-technical-metadata-modal"; diff --git a/admin/src/domains/customer-content/reviews/modals/review-technical-metadata-modal/review-technical-metadata-modal.tsx b/admin/src/domains/customer-content/reviews/modals/review-technical-metadata-modal/review-technical-metadata-modal.tsx new file mode 100644 index 000000000..94da71f2d --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/review-technical-metadata-modal/review-technical-metadata-modal.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Alert, Descriptions, Skeleton, Typography } from "antd"; +import { CopyableChip } from "@/ui-kit/copyable-chip"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { useReview } from "../../hooks"; +import type { ReviewModalPayload } from "../../modals"; +import { humanizeEnum } from "../../components/review-details-card/review-details-card.utils"; +import { useReviewDetailsStyles } from "../../components/review-details-card/review-details-card.styles"; + +function hasMetadata(value: unknown) { + return Boolean(value && typeof value === "object" && Object.keys(value as object).length); +} + +export function ReviewTechnicalMetadataModal() { + const { styles } = useReviewDetailsStyles(); + const { payload, pop } = useModalStackContext(); + const value = payload as ReviewModalPayload; + const query = useReview(value.entityId); + const review = query.review; + return ( + + {query.loading && !review ? : null} + {query.error ? : null} + {!query.loading && !review ? : null} + {review ? ( + + + : , + }, + { + key: "idempotency", + label: "Idempotency key", + children: review.idempotencyKey ? : , + }, + { key: "source", label: "Source", children: humanizeEnum(review.sourceChannel) }, + ]} + /> + {hasMetadata(review.sourceMetadata) ? ( +
    + Source metadata +
    +                {JSON.stringify(review.sourceMetadata, null, 2)}
    +              
    +
    + ) : null} +
    + ) : null} +
    + ); +} diff --git a/admin/src/domains/customer-content/reviews/modals/shared/review-form.styles.ts b/admin/src/domains/customer-content/reviews/modals/shared/review-form.styles.ts new file mode 100644 index 000000000..f0fc16211 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/shared/review-form.styles.ts @@ -0,0 +1,76 @@ +import { createStyles } from "antd-style"; + +export const useReviewFormStyles = createStyles(({ token }) => ({ + field: { + flex: 1, + minWidth: 0, + }, + fieldGrid: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: token.margin, + "@media (max-width: 640px)": { + gridTemplateColumns: "1fr", + }, + }, + label: { + display: "block", + marginBottom: 4, + fontSize: token.fontSize, + fontWeight: 500, + }, + error: { + display: "block", + color: token.colorError, + fontSize: token.fontSizeSM, + marginTop: 4, + }, + help: { + display: "block", + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + marginTop: 4, + }, + contextPanel: { + padding: token.paddingSM, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + background: token.colorBgLayout, + }, + contextPending: { + borderColor: token.colorWarningBorder, + background: token.colorWarningBg, + }, + contextPublished: { + borderColor: token.colorSuccessBorder, + background: token.colorSuccessBg, + }, + contextRejected: { + borderColor: token.colorErrorBorder, + background: token.colorErrorBg, + }, + currentValue: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + mediaItemMeta: { + display: "inline-flex", + alignItems: "center", + gap: 4, + }, + mediaPreview: { + width: "100%", + maxHeight: 520, + objectFit: "contain", + borderRadius: token.borderRadius, + background: token.colorBgLayout, + }, + mediaItemLayout: { + display: "grid", + gridTemplateColumns: "minmax(240px, 1fr) minmax(0, 1.5fr)", + gap: token.margin, + "@media (max-width: 760px)": { + gridTemplateColumns: "1fr", + }, + }, +})); diff --git a/admin/src/domains/customer-content/reviews/modals/shared/review-section-modal.tsx b/admin/src/domains/customer-content/reviews/modals/shared/review-section-modal.tsx new file mode 100644 index 000000000..23da3df24 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/modals/shared/review-section-modal.tsx @@ -0,0 +1,158 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; +import type { FieldValues, Path, UseFormSetError } from "react-hook-form"; +import { Alert, App, Button, Skeleton, Typography } from "antd"; +import type { ApiReviewUpdateInput } from "@/graphql/types"; +import type { ApiGenericUserError } from "@/graphql/types"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { useReview, useUpdateReview } from "../../hooks"; +import type { ReviewSectionModalPayload } from "../../modals"; +import { useReviewFormStyles } from "./review-form.styles"; + +export function ReviewFormField({ + label, + error, + help, + children, +}: { + label: string; + error?: string; + help?: string; + children: ReactNode; +}) { + const { styles } = useReviewFormStyles(); + return ( +
    + {label} + {children} + {error ? {error} : null} + {!error && help ? {help} : null} +
    + ); +} + +interface ReviewModalFrameProps { + name: string; + title: string; + children: ReactNode; + loading: boolean; + disabled: boolean; + onSubmit: () => void; + onClose: () => void; + queryLoading: boolean; + hasReview: boolean; + error: string | null; + conflict: boolean; + onReload: () => void; +} + +export function ReviewModalFrame({ + name, + title, + children, + loading, + disabled, + onSubmit, + onClose, + queryLoading, + hasReview, + error, + conflict, + onReload, +}: ReviewModalFrameProps) { + return ( + + )} + > + {conflict ? ( + Reload latest data} + /> + ) : null} + {error ? : null} + {queryLoading && !hasReview ? : children} + {!queryLoading && !hasReview ? ( + + ) : null} + + ); +} + +export function useReviewSectionModal(successMessage: string) { + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as ReviewSectionModalPayload; + const query = useReview(value.entityId); + const mutation = useUpdateReview(); + const [error, setError] = useState(null); + const [conflict, setConflict] = useState(false); + const [reloadVersion, setReloadVersion] = useState(0); + + const save = useCallback(async ( + operations: ApiReviewUpdateInput, + fieldMap: Record>, + setFieldError: UseFormSetError, + onUserErrors?: (errors: ApiGenericUserError[]) => void, + ) => { + if (!query.review) return false; + setError(null); + setConflict(false); + const result = await mutation.updateReview(query.review.id, query.review.revision, operations); + if (!result.review || result.userErrors.length) { + onUserErrors?.(result.userErrors); + const isConflict = result.userErrors.some((item) => item.code === "REVISION_CONFLICT"); + setConflict(isConflict); + const unmapped: string[] = []; + for (const item of result.userErrors) { + if (item.code === "REVISION_CONFLICT") continue; + const path = item.field?.join(".") ?? ""; + const match = Object.entries(fieldMap).find(([apiPath]) => path === apiPath || path.endsWith(`.${apiPath}`)); + if (match) setFieldError(match[1], { message: item.message }); + else unmapped.push(item.message); + } + if (unmapped.length) setError(unmapped.join(" ")); + return false; + } + await value.onSaved?.(); + setDirty(false); + message.success(successMessage); + forcePop(); + return true; + }, [forcePop, message, mutation, query.review, setDirty, successMessage, value]); + + const reloadLatest = useCallback(async () => { + setError(null); + await query.refetch(); + setConflict(false); + setReloadVersion((current) => current + 1); + }, [query]); + + return { + payload: value, + review: query.review, + queryLoading: query.loading, + queryError: query.error, + mutationLoading: mutation.loading, + mutationError: mutation.error, + error: error ?? query.error?.message ?? mutation.error?.message ?? null, + conflict, + reloadVersion, + reloadLatest, + save, + pop, + setDirty, + }; +} diff --git a/admin/src/domains/customer-content/reviews/page/filter-schema.ts b/admin/src/domains/customer-content/reviews/page/filter-schema.ts new file mode 100644 index 000000000..efb48d717 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/page/filter-schema.ts @@ -0,0 +1,87 @@ +import { + FilterType, + dateOperators, + enumOperators, + numberOperators, + relationOperators, +} from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { + ReviewContentStatus, + ReviewVerificationStatus, +} from "@/graphql/types"; + +/** Required operational filters for review moderation and customer support. */ +export const filterSchema: IFilterSchema[] = [ + { + key: "status", + label: "Status", + description: "Filter by moderation status", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "status", + options: [ + { label: "Pending", value: ReviewContentStatus.Pending }, + { label: "Published", value: ReviewContentStatus.Published }, + { label: "Rejected", value: ReviewContentStatus.Rejected }, + ], + }, + { + key: "rating", + label: "Rating", + description: "Filter by star rating", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "rating", + options: [5, 4, 3, 2, 1].map((rating) => ({ + label: `${rating} star${rating === 1 ? "" : "s"}`, + value: rating, + })), + }, + { + key: "product", + label: "Product", + description: "Filter reviews for selected products", + type: FilterType.Relation, + operators: relationOperators, + payloadKey: "productId", + entity: "product", + }, + { + key: "verificationStatus", + label: "Verification", + description: "Filter by purchase verification status", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "verificationStatus", + options: [ + { label: "Verified", value: ReviewVerificationStatus.Verified }, + { label: "Unverified", value: ReviewVerificationStatus.Unverified }, + { label: "Revoked", value: ReviewVerificationStatus.Revoked }, + ], + }, + { + key: "mediaCount", + label: "Media count", + description: "Filter by the number of customer media items", + type: FilterType.Number, + operators: numberOperators, + payloadKey: "mediaCount", + }, + { + key: "reportCount", + label: "Abuse reports", + description: "Filter by the number of customer abuse reports", + type: FilterType.Number, + operators: numberOperators, + payloadKey: "reportCount", + }, + { + key: "createdAt", + label: "Submitted date", + description: "Filter by review submission date", + type: FilterType.DateRange, + operators: dateOperators, + payloadKey: "createdAt", + }, +]; diff --git a/admin/src/domains/customer-content/reviews/page/page-config.ts b/admin/src/domains/customer-content/reviews/page/page-config.ts new file mode 100644 index 000000000..6ce215940 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/page/page-config.ts @@ -0,0 +1,46 @@ +import type { SortFieldMapping, UsePageConfigReturn } from "@/hooks"; +import type { + ApiReviewOrderByInput, + ApiReviewWhereInput, +} from "@/graphql/types"; +import { ReviewOrderField } from "@/graphql/types"; +import type { ReviewsQueryVariables } from "../graphql/operation-types"; + +export const reviewSortFieldMapping: SortFieldMapping = { + createdAt: ReviewOrderField.CreatedAt, + likeCount: ReviewOrderField.LikeCount, + dislikeCount: ReviewOrderField.DislikeCount, + isVerifiedPurchase: ReviewOrderField.VerificationStatus, + rating: ReviewOrderField.Rating, + reportCount: ReviewOrderField.ReportCount, + status: ReviewOrderField.Status, + updatedAt: ReviewOrderField.UpdatedAt, +}; + +export const buildReviewSearchCondition = ( + search: string, +): Partial => ({ + _or: [ + { title: { _containsi: search } }, + { body: { _containsi: search } }, + ], +}); + +export function buildReviewsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): ReviewsQueryVariables { + return { + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: pageConfig.orderBy?.map((order) => ({ + field: order.field, + direction: order.direction, + })) as ApiReviewOrderByInput[] | undefined, + }; +} diff --git a/admin/src/domains/customer-content/reviews/page/page.tsx b/admin/src/domains/customer-content/reviews/page/page.tsx new file mode 100644 index 000000000..f92eb77d7 --- /dev/null +++ b/admin/src/domains/customer-content/reviews/page/page.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, Button, Flex, Tag, Typography } from "antd"; +import { LuCircleCheckBig as CheckCircleFilled, LuThumbsDown as DislikeOutlined, LuFlag as FlagOutlined, LuThumbsUp as LikeOutlined, LuImage as PictureOutlined, LuPlus as PlusOutlined, LuShoppingBag as ShoppingOutlined, LuStar as StarFilled } from "react-icons/lu"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { + AllCommunityModule, + GridStateModule, + ModuleRegistry, + type ColDef, +} from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { ApiReview, ApiReviewWhereInput } from "@/graphql/types"; +import { ReviewContentStatus, ReviewOrderField } from "@/graphql/types"; +import { useReviews } from "../hooks"; +import { useReviewCreateModal, useReviewModal } from "../modals"; +import { useUgcNavigation } from "../../use-ugc-navigation"; +import { filterSchema } from "./filter-schema"; +import { + buildReviewSearchCondition, + buildReviewsQueryVariables, + reviewSortFieldMapping, +} from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); + +const statusConfig: Record = { + [ReviewContentStatus.Pending]: { color: "gold", label: "Pending" }, + [ReviewContentStatus.Published]: { color: "green", label: "Published" }, + [ReviewContentStatus.Rejected]: { color: "red", label: "Rejected" }, +}; + +function RatingCell({ value }: CustomCellRendererProps) { + return ( + + + {value ?? 0}.0 + + ); +} + +function ReviewCell({ data }: CustomCellRendererProps) { + if (!data) return null; + + return ( + + + {data.title ?? "Untitled review"} + + + {data.body} + + + ); +} + +function ProductCell({ data }: CustomCellRendererProps) { + if (!data) return null; + + return ( + + + + {data.product.title} + + + ); +} + +function CustomerCell({ data }: CustomCellRendererProps) { + if (!data) return null; + + return ( + + {data.author.displayName} + + {data.author.email ?? data.author.type} + + + ); +} + +function StatusCell({ value }: CustomCellRendererProps) { + const config = statusConfig[value ?? ReviewContentStatus.Pending]; + return {config.label}; +} + +function VerifiedCell({ value }: CustomCellRendererProps) { + return value ? ( + + + Verified + + ) : ( + Unverified + ); +} + +function SignalsCell({ data }: CustomCellRendererProps) { + if (!data) return null; + + return ( + + + + {data.metrics.likeCount} + + + + {data.metrics.dislikeCount} + + + 0 ? "#cf1322" : undefined }} /> + 0 ? "danger" : undefined}> + {data.metrics.reportCount} + + + {data.metrics.mediaCount > 0 && ( + + + {data.metrics.mediaCount} + + )} + + ); +} + +const reviewDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +function DateCell({ value }: CustomCellRendererProps) { + return ( + {value ? reviewDateFormatter.format(new Date(value)) : ""} + ); +} + +export default function CustomerReviewsPage() { + const { backToUgc } = useUgcNavigation(); + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig({ + gridRef, + storageKey: "reviews-grid-state", + filterSchema, + sortFieldMapping: reviewSortFieldMapping, + defaultSort: [{ colId: "createdAt", sort: "desc" }], + defaultPageSize: 10, + pageSizeOptions: [10, 20], + buildSearchCondition: buildReviewSearchCondition, + }); + const variables = useMemo( + () => buildReviewsQueryVariables({ first: pageConfig.first, after: pageConfig.after, last: pageConfig.last, before: pageConfig.before, where: pageConfig.where, orderBy: pageConfig.orderBy }), + [ + pageConfig.first, + pageConfig.after, + pageConfig.last, + pageConfig.before, + pageConfig.where, + pageConfig.orderBy, + ], + ); + const { reviews, totalCount, pageInfo, loading, error, refetch } = useReviews(variables); + const { push: openReviewModal } = useReviewModal(); + const { push: openReviewCreateModal } = useReviewCreateModal(); + + const handleCreateReview = useCallback(() => { + openReviewCreateModal({ onSaved: refetch }); + }, [openReviewCreateModal, refetch]); + + const handleEditReview = useCallback( + (review: ApiReview) => { + openReviewModal({ entityId: review.id, onSaved: refetch }); + }, + [openReviewModal, refetch], + ); + + const handleNextPage = useCallback(() => { + if (pageInfo?.endCursor) pageConfig.goToNextPage(pageInfo.endCursor); + }, [pageConfig, pageInfo?.endCursor]); + + const handlePrevPage = useCallback(() => { + if (pageInfo?.startCursor) pageConfig.goToPrevPage(pageInfo.startCursor); + }, [pageConfig, pageInfo?.startCursor]); + + const columnDefs = useMemo[]>( + () => [ + { + headerName: "Rating", + field: "rating", + cellRenderer: RatingCell, + width: 105, + }, + { + headerName: "Review", + colId: "review", + cellRenderer: ReviewCell, + minWidth: 320, + flex: 2, + sortable: false, + }, + { + headerName: "Product", + colId: "product", + cellRenderer: ProductCell, + minWidth: 230, + flex: 1, + sortable: false, + }, + { + headerName: "Customer", + colId: "customer", + cellRenderer: CustomerCell, + minWidth: 210, + sortable: false, + }, + { + headerName: "Status", + field: "status", + cellRenderer: StatusCell, + width: 125, + }, + { + headerName: "Purchase", + field: "isVerifiedPurchase", + cellRenderer: VerifiedCell, + width: 135, + }, + { + headerName: "Signals", + colId: "likeCount", + cellRenderer: SignalsCell, + minWidth: 210, + }, + { + headerName: "Submitted", + field: "createdAt", + cellRenderer: DateCell, + minWidth: 180, + }, + ], + [], + ); + + const defaultColDef = useMemo>( + () => ({ + resizable: true, + sortable: true, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }), + [], + ); + + return ( + } onClick={handleCreateReview}> + Create review + + } + > + + } + /> + +
    + {error && ( + + )} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={reviews} + loading={loading} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={68} + suppressCellFocus + suppressMovableColumns + rowStyle={{ cursor: "pointer" }} + onRowClicked={({ data }) => { + if (data) handleEditReview(data); + }} + onSortChanged={pageConfig.onSortChanged} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + /> +
    + + +
    +
    + ); +} diff --git a/admin/src/domains/customer-content/shared/components/content-details-sections.tsx b/admin/src/domains/customer-content/shared/components/content-details-sections.tsx new file mode 100644 index 000000000..d5e42926f --- /dev/null +++ b/admin/src/domains/customer-content/shared/components/content-details-sections.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { Button, Descriptions, Empty, Flex, List, Tag, Typography } from "antd"; +import { createStyles } from "antd-style"; +import { LuFlag as FlagOutlined, LuThumbsDown as DislikeOutlined, LuThumbsUp as LikeOutlined, LuUsers as TeamOutlined } from "react-icons/lu"; +import type { ApiReviewContent } from "@/graphql/types"; +import { ReviewContentReportReason } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; + +const useStyles = createStyles(({ token }) => ({ + engagementGrid: { + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: token.paddingSM, + "@media (max-width: 640px)": { gridTemplateColumns: "1fr" }, + }, + engagementMetric: { + padding: token.paddingSM, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + background: token.colorFillAlter, + }, + metricIcon: { color: token.colorTextSecondary, fontSize: 18 }, + metricValue: { margin: "0 !important" }, + reportsList: { + overflow: "hidden", + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadius, + }, + reportRow: { + padding: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0 }, + }, +})); + +interface ContentDetailsSectionsProps { + content: ApiReviewContent; + onManageExternalReferences?: () => void; +} + +const formatDate = (value?: string | null) => value + ? new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) + : "—"; +const pretty = (value: unknown) => JSON.stringify(value, null, 2); +const reportReasonCopy: Record = { + [ReviewContentReportReason.ConflictOfInterest]: "Conflict of interest", + [ReviewContentReportReason.FraudOrScam]: "Fraud or scam", + [ReviewContentReportReason.Harassment]: "Harassment", + [ReviewContentReportReason.HateSpeech]: "Hate speech", + [ReviewContentReportReason.IllegalContent]: "Illegal content", + [ReviewContentReportReason.IntellectualProperty]: "Intellectual property", + [ReviewContentReportReason.NotRelevant]: "Not relevant to product", + [ReviewContentReportReason.Offensive]: "Offensive content", + [ReviewContentReportReason.Other]: "Other", + [ReviewContentReportReason.PersonalInformation]: "Personal information", + [ReviewContentReportReason.Spam]: "Spam or promotion", +}; +const reportDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +export function ContentDetailsSections({ + content, + onManageExternalReferences, +}: ContentDetailsSectionsProps) { + const { styles } = useStyles(); + const reports = content.reports.edges.map((edge) => edge.node); + const references = content.externalReferences.edges.map((edge) => edge.node); + + return ( + <> + + } /> + {content.author.customer.id} : "—" }, + { key: "principal", label: "Principal", children: content.author.principalId ?? "—" }, + { key: "source", label: "Source", children: content.sourceChannel }, + { key: "idempotency", label: "Idempotency key", children: content.idempotencyKey ? {content.idempotencyKey} : "—" }, + ]} /> + {Object.keys(content.sourceMetadata).length ?
    {pretty(content.sourceMetadata)}
    : null} +
    + + + + +
    + + + + Likes + + + {content.metrics.likeCount} + + Customers who found the review helpful. + + + + + Dislikes + + + {content.metrics.dislikeCount} + + Customers who found the review unhelpful. + + + + + Abuse reports + + + {content.metrics.reportCount} + + Customers who asked to inspect this review. + +
    + + {reports.length > 0 ? ( +
    + {reports.map((report) => ( + + + + {reportReasonCopy[report.reason]} + {report.reporterCustomer?.displayName ?? "Anonymous reporter"} + {report.reporterCustomer?.email ? ( + {report.reporterCustomer.email} + ) : null} + + + {reportDateFormatter.format(new Date(report.createdAt))} + + + {report.details ? {report.details} : null} + + ))} +
    + ) : ( + No abuse reports were submitted for this review. + )} +
    +
    + + + Manage : undefined} /> + {references.length ? {item.syncStatus.toLowerCase()}} /> : } + + + + ); +} diff --git a/admin/src/domains/customer-content/shared/components/index.ts b/admin/src/domains/customer-content/shared/components/index.ts new file mode 100644 index 000000000..607d948b1 --- /dev/null +++ b/admin/src/domains/customer-content/shared/components/index.ts @@ -0,0 +1 @@ +export { ContentDetailsSections } from "./content-details-sections"; diff --git a/admin/src/domains/customer-content/shared/graphql/actions.ts b/admin/src/domains/customer-content/shared/graphql/actions.ts new file mode 100644 index 000000000..5c88473d6 --- /dev/null +++ b/admin/src/domains/customer-content/shared/graphql/actions.ts @@ -0,0 +1,25 @@ +import { gql } from "@apollo/client"; + +export const REVIEW_DELETE_MUTATION = gql` + mutation AdminReviewDelete($input: ReviewContentDeleteInput!) { + reviewsMutation { reviewDelete(input: $input) { deletedReviewId userErrors { code field message } } } + } +`; + +export const QUESTION_DELETE_MUTATION = gql` + mutation AdminQuestionDelete($input: ReviewContentDeleteInput!) { + reviewsMutation { productQuestionDelete(input: $input) { deletedProductQuestionId userErrors { code field message } } } + } +`; + +export const CONTENT_REDACT_MUTATION = gql` + mutation AdminContentRedact($contentId: ID!, $expectedRevision: Int!) { + reviewsMutation { + contentRedact(contentId: $contentId, expectedRevision: $expectedRevision) { + content { id revision redactedAt updatedAt } + operationResults { errors { code field message } } + userErrors { code field message } + } + } + } +`; diff --git a/admin/src/domains/customer-content/shared/graphql/index.ts b/admin/src/domains/customer-content/shared/graphql/index.ts new file mode 100644 index 000000000..8ff5a89af --- /dev/null +++ b/admin/src/domains/customer-content/shared/graphql/index.ts @@ -0,0 +1 @@ +export * from "./actions"; diff --git a/admin/src/domains/customer-content/shared/hooks/index.ts b/admin/src/domains/customer-content/shared/hooks/index.ts new file mode 100644 index 000000000..2aee9a145 --- /dev/null +++ b/admin/src/domains/customer-content/shared/hooks/index.ts @@ -0,0 +1 @@ +export { useContentActions } from "./use-content-actions"; diff --git a/admin/src/domains/customer-content/shared/hooks/use-content-actions.ts b/admin/src/domains/customer-content/shared/hooks/use-content-actions.ts new file mode 100644 index 000000000..cbbfaf506 --- /dev/null +++ b/admin/src/domains/customer-content/shared/hooks/use-content-actions.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiGenericUserError, ApiReviewContentDeleteInput } from "@/graphql/types"; +import { + CONTENT_REDACT_MUTATION, + QUESTION_DELETE_MUTATION, + REVIEW_DELETE_MUTATION, +} from "../graphql/actions"; + +type MutationErrors = { userErrors: ApiGenericUserError[]; operationResults?: Array<{ errors: ApiGenericUserError[] }> }; +type ReviewDeleteData = { reviewsMutation: { reviewDelete: MutationErrors & { deletedReviewId?: string | null } } }; +type QuestionDeleteData = { reviewsMutation: { productQuestionDelete: MutationErrors & { deletedProductQuestionId?: string | null } } }; +type ContentUpdateData = { reviewsMutation: { contentRedact: MutationErrors & { content?: { id: string; revision: number; updatedAt: string } | null } } }; + +function collectErrors(payload?: MutationErrors | null) { + return [...(payload?.userErrors ?? []), ...(payload?.operationResults?.flatMap((item) => item.errors) ?? [])]; +} + +export function useContentActions() { + const [deleteReviewMutation, deleteReviewState] = useMutation(REVIEW_DELETE_MUTATION); + const [deleteQuestionMutation, deleteQuestionState] = useMutation(QUESTION_DELETE_MUTATION); + const [redactMutation, redactState] = useMutation(CONTENT_REDACT_MUTATION); + + const deleteReview = useCallback(async (input: ApiReviewContentDeleteInput) => { + const result = await deleteReviewMutation({ variables: { input } }); + const payload = result.data?.reviewsMutation.reviewDelete; + return { id: payload?.deletedReviewId ?? null, errors: collectErrors(payload) }; + }, [deleteReviewMutation]); + + const deleteQuestion = useCallback(async (input: ApiReviewContentDeleteInput) => { + const result = await deleteQuestionMutation({ variables: { input } }); + const payload = result.data?.reviewsMutation.productQuestionDelete; + return { id: payload?.deletedProductQuestionId ?? null, errors: collectErrors(payload) }; + }, [deleteQuestionMutation]); + + const redact = useCallback(async (contentId: string, expectedRevision: number) => { + const result = await redactMutation({ variables: { contentId, expectedRevision } }); + const payload = result.data?.reviewsMutation.contentRedact; + return { content: payload?.content ?? null, errors: collectErrors(payload) }; + }, [redactMutation]); + + return { + deleteReview, + deleteQuestion, + redact, + loading: deleteReviewState.loading || deleteQuestionState.loading || redactState.loading, + error: deleteReviewState.error ?? deleteQuestionState.error ?? redactState.error ?? null, + }; +} diff --git a/admin/src/domains/customer-content/use-ugc-navigation.ts b/admin/src/domains/customer-content/use-ugc-navigation.ts new file mode 100644 index 000000000..12f270cee --- /dev/null +++ b/admin/src/domains/customer-content/use-ugc-navigation.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { usePathParams } from "@/registry"; + +export function useUgcNavigation() { + const router = useRouter(); + const { resolvePath } = usePathParams(); + const backToUgc = useCallback(() => { + router.push(resolvePath("/:orgName/:storeName/customer-content")); + }, [resolvePath, router]); + + return { backToUgc }; +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-card.styles.ts b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-card.styles.ts new file mode 100644 index 000000000..f2ab5dfc7 --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-card.styles.ts @@ -0,0 +1,233 @@ +import { createStyles } from "antd-style"; + +export const useCustomerDetailsStyles = createStyles(({ token }) => ({ + container: { + display: "flex", + flexDirection: "column", + gap: 12, + width: "100%", + }, + headerMeta: { + minWidth: 0, + flexWrap: "wrap", + }, + statusTag: { + margin: 0, + display: "inline-flex", + alignItems: "center", + gap: 4, + fontWeight: 600, + }, + metaText: { + fontSize: token.fontSizeSM, + }, + headerActions: { + flexShrink: 0, + }, + iconButton: { + paddingInline: 6, + }, + identity: { + minWidth: 0, + }, + avatar: { + flexShrink: 0, + background: token.colorPrimaryBg, + color: token.colorPrimary, + fontWeight: 600, + }, + customerTitle: { + margin: 0, + minWidth: 0, + }, + contactLine: { + minWidth: 0, + rowGap: 4, + }, + contact: { + display: "inline-flex", + alignItems: "center", + gap: 6, + minWidth: 0, + }, + verificationIcon: { + display: "inline-flex", + flexShrink: 0, + }, + divider: { + marginBlock: token.margin, + }, + kpiGrid: { + display: "grid", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gap: token.marginSM, + "@media (max-width: 720px)": { + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + }, + "@media (max-width: 420px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + kpiTile: { + minWidth: 0, + }, + subsectionLabel: { + display: "block", + color: token.colorTextSecondary, + marginBottom: token.marginXS, + }, + primaryValue: { + display: "block", + fontSize: token.fontSizeLG, + fontWeight: 600, + }, + sectionDivider: { + marginBlock: token.margin, + }, + twoColumns: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: token.marginXL, + "@media (max-width: 720px)": { + gridTemplateColumns: "minmax(0, 1fr)", + gap: token.margin, + }, + }, + metricRows: { + display: "grid", + gap: token.marginXS, + }, + metricRow: { + display: "flex", + justifyContent: "space-between", + gap: token.margin, + }, + detailRows: { + display: "grid", + }, + detailRow: { + display: "grid", + gridTemplateColumns: "140px minmax(0, 1fr) auto", + alignItems: "center", + gap: token.margin, + paddingBlock: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0, paddingBottom: 0 }, + "@media (max-width: 560px)": { + gridTemplateColumns: "minmax(0, 1fr)", + gap: token.marginXXS, + }, + }, + addressGrid: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: token.marginSM, + "@media (max-width: 720px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + addressCard: { + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + padding: token.padding, + minWidth: 0, + }, + addressText: { + whiteSpace: "pre-line", + }, + showAll: { + marginTop: token.marginSM, + paddingInline: 0, + }, + consentRow: { + display: "grid", + gridTemplateColumns: "40px minmax(0, 1fr)", + gap: token.margin, + paddingBlock: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0, paddingBottom: 0 }, + }, + channelIcon: { + width: 40, + height: 40, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + display: "grid", + placeItems: "center", + color: token.colorTextSecondary, + fontSize: token.fontSizeLG, + }, + classificationRow: { + display: "grid", + gridTemplateColumns: "110px minmax(0, 1fr)", + gap: token.margin, + paddingBlock: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0, paddingBottom: 0 }, + "@media (max-width: 520px)": { + gridTemplateColumns: "minmax(0, 1fr)", + gap: token.marginXXS, + }, + }, + taxRow: { + display: "grid", + gridTemplateColumns: "auto minmax(0, 1fr) auto", + alignItems: "center", + gap: token.marginSM, + paddingBlock: token.paddingSM, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { borderBottom: 0 }, + "@media (max-width: 620px)": { + gridTemplateColumns: "minmax(0, 1fr)", + alignItems: "start", + }, + }, + statusStrip: { + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadiusLG, + overflow: "hidden", + "@media (max-width: 420px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + statusSegment: { + padding: token.paddingSM, + textAlign: "center", + borderRight: `1px solid ${token.colorBorder}`, + "&:last-child": { borderRight: 0 }, + "@media (max-width: 420px)": { + borderRight: 0, + borderBottom: `1px solid ${token.colorBorder}`, + "&:last-child": { borderBottom: 0 }, + }, + }, + statusSegmentActive: { + fontWeight: 600, + }, + statusSegmentSuccess: { + background: token.colorSuccessBg, + color: token.colorSuccessText, + }, + statusSegmentDisabled: { + background: token.colorFillSecondary, + color: token.colorTextSecondary, + }, + statusSegmentBlocked: { + background: token.colorErrorBg, + color: token.colorErrorText, + }, + terminalPanel: { + padding: token.padding, + borderRadius: token.borderRadiusLG, + background: token.colorFillTertiary, + }, + noteBlock: { + paddingTop: token.paddingSM, + }, + truncated: { + display: "block", + marginTop: token.marginSM, + }, +})); diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-card.tsx b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-card.tsx new file mode 100644 index 000000000..e689471d0 --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-card.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { ApiCustomer } from "@/graphql/types"; +import { useDefaultCurrency } from "@/domains/workspace"; +import type { CustomerEditSection } from "../../modals"; +import { useCustomerDetailsStyles } from "./customer-details-card.styles"; +import { CustomerInfoHeader } from "./customer-info-header"; +import { + CustomerAccountSection, + CustomerAddressesSection, + CustomerClassificationSection, + CustomerConsentsSection, + CustomerOrderActivitySection, + CustomerProfileSection, + CustomerTaxSection, +} from "./sections"; + +interface CustomerDetailsCardProps { + customer: ApiCustomer; + onEdit: (section: CustomerEditSection) => void; + onDelete: () => void; + onTechnicalMetadata: () => void; +} + +export function CustomerDetailsCard({ customer, onEdit, onDelete, onTechnicalMetadata }: CustomerDetailsCardProps) { + const { styles } = useCustomerDetailsStyles(); + const currency = useDefaultCurrency(); + + return ( +
    + + + + + + + + +
    + ); +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-utils.ts b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-utils.ts new file mode 100644 index 000000000..1128c74fd --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-details-utils.ts @@ -0,0 +1,76 @@ +import { shopCountries, shopLocales } from "@/defs/localization"; +import { formatDetailDate } from "@/domains/inventory/utils/format-detail-date"; +import { CustomerTaxExemptionStatus, CustomerTaxIdentifierStatus } from "@/graphql/types"; + +export const customerTaxIdentifierStatusColor: Partial> = { + [CustomerTaxIdentifierStatus.Verified]: "green", + [CustomerTaxIdentifierStatus.Unverified]: "gold", + [CustomerTaxIdentifierStatus.Rejected]: "red", + [CustomerTaxIdentifierStatus.Expired]: "default", +}; + +export const customerTaxExemptionStatusColor: Partial> = { + [CustomerTaxExemptionStatus.Active]: "green", + [CustomerTaxExemptionStatus.Revoked]: "red", + [CustomerTaxExemptionStatus.Expired]: "default", +}; + +export function compactParts(parts: Array, separator = " · ") { + return parts.filter((part): part is string => Boolean(part?.trim())).join(separator); +} + +export function enumLabel(value?: string | null) { + if (!value) return ""; + return value + .toLowerCase() + .replaceAll("_", " ") + .replace(/(^|\s)\S/g, (letter) => letter.toUpperCase()); +} + +export function formatCustomerDate(value?: string | null) { + return value ? formatDetailDate(value) : "—"; +} + +export function formatCustomerDateTime(value?: string | null) { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function formatCustomerMoney(value: unknown, currency: string | null) { + if (value === null || value === undefined || !currency) return "—"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + currencyDisplay: "narrowSymbol", + }).format(Number(value) / 100); +} + +export function formatCustomerSource(value?: string | null) { + return enumLabel(value); +} + +export function formatLocale(value?: string | null) { + if (!value) return null; + const locale = shopLocales.find((item) => item.value === value); + return locale ? `${locale.name} (${locale.value})` : value; +} + +export function formatCountry(value?: string | null) { + if (!value) return null; + const country = shopCountries.find((item) => item.value === value); + return country ? `${country.name} (${country.value})` : value; +} + +export function customerInitials(firstName?: string | null, lastName?: string | null) { + return `${firstName?.[0] ?? ""}${lastName?.[0] ?? ""}`.toUpperCase(); +} + +export function shortCustomerId(value: string) { + const localId = value.split("/").at(-1) ?? value; + return localId.length > 8 ? `${localId.slice(0, 8)}…` : localId; +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/customer-info-header.tsx b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-info-header.tsx new file mode 100644 index 000000000..cbd6db78f --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/customer-info-header.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState } from "react"; +import { Avatar, Button, Divider, Dropdown, Flex, Tag, Tooltip, Typography } from "antd"; +import { + LuBadgeCheck as VerifiedOutlined, + LuBan as BlockedOutlined, + LuCheck as CheckOutlined, + LuEllipsis as MoreOutlined, + LuLink as LinkOutlined, + LuLock as LockOutlined, + LuMerge as MergeOutlined, + LuPause as PauseOutlined, + LuUser as UserOutlined, +} from "react-icons/lu"; +import type { ApiCustomer } from "@/graphql/types"; +import { CustomerLifecycleStatus } from "@/graphql/types"; +import { CopyableChip } from "@/ui-kit/copyable-chip"; +import { KPITile } from "@/ui-kit/kpi-tile"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { CustomerEditSection } from "../../modals"; +import { useCustomerDetailsStyles } from "./customer-details-card.styles"; +import { + compactParts, + customerInitials, + enumLabel, + formatCustomerDate, + formatCustomerMoney, + formatCustomerSource, + shortCustomerId, +} from "./customer-details-utils"; + +interface CustomerInfoHeaderProps { + customer: ApiCustomer; + currency: string | null; + onEdit: (section: CustomerEditSection) => void; + onTechnicalMetadata: () => void; + onDelete: () => void; +} + +const lifecycle = { + [CustomerLifecycleStatus.Active]: { + color: "green", + icon: , + hint: "Customer can participate in normal store flows", + }, + [CustomerLifecycleStatus.Disabled]: { + color: "default", + icon: , + hint: "Customer account is disabled by an administrator", + }, + [CustomerLifecycleStatus.Blocked]: { + color: "red", + icon: , + hint: "Customer is blocked; see account details for the reason", + }, + [CustomerLifecycleStatus.Merged]: { + color: "purple", + icon: , + hint: "Profile was merged into another customer", + }, + [CustomerLifecycleStatus.Redacted]: { + color: "default", + icon: , + hint: "Personal data was redacted by a privacy workflow", + }, +} as const; + +function VerificationIcon({ type, verified }: { type: "Email" | "Phone"; verified: boolean }) { + const label = `${type} ${verified ? "verified" : "not verified"}`; + return ( + + + + + + ); +} + +export function CustomerInfoHeader({ + customer, + currency, + onEdit, + onTechnicalMetadata, + onDelete, +}: CustomerInfoHeaderProps) { + const { styles } = useCustomerDetailsStyles(); + const [linkCopied, setLinkCopied] = useState(false); + const status = lifecycle[customer.lifecycleStatus]; + const terminal = customer.lifecycleStatus === CustomerLifecycleStatus.Merged || customer.lifecycleStatus === CustomerLifecycleStatus.Redacted; + const money = customer.monetaryStatistics.edges[0]?.node ?? null; + const meta = compactParts([ + `Customer since ${formatCustomerDate(customer.createdAt)}`, + `Updated ${formatCustomerDate(customer.updatedAt)}`, + formatCustomerSource(customer.source), + ]); + + const copyAdminLink = async () => { + if (typeof window === "undefined") return; + await navigator.clipboard.writeText(window.location.href); + setLinkCopied(true); + window.setTimeout(() => setLinkCopied(false), 1500); + }; + + const menuItems = [ + { key: "profile", label: "Edit personal profile", "data-testid": "customer-edit-profile-menu-item", onClick: () => onEdit("profile") }, + { key: "contact", label: "Edit contact details", "data-testid": "customer-edit-contact-menu-item", onClick: () => onEdit("contact") }, + { key: "company", label: "Edit company", "data-testid": "customer-edit-company-menu-item", onClick: () => onEdit("company") }, + { key: "addresses", label: "Manage addresses", "data-testid": "customer-manage-addresses-menu-item", onClick: () => onEdit("addresses") }, + { type: "divider" as const }, + { key: "consents", label: "Edit marketing consents", "data-testid": "customer-edit-consents-menu-item", onClick: () => onEdit("consents") }, + { key: "groups", label: "Edit customer groups", "data-testid": "customer-edit-groups-menu-item", onClick: () => onEdit("groups") }, + { key: "tags", label: "Edit customer tags", "data-testid": "customer-edit-tags-menu-item", onClick: () => onEdit("tags") }, + { key: "segments", label: "Edit manual segments", "data-testid": "customer-edit-segments-menu-item", onClick: () => onEdit("segments") }, + { type: "divider" as const }, + { key: "status", label: "Change customer status", "data-testid": "customer-edit-status-menu-item", disabled: terminal, onClick: () => onEdit("status") }, + { key: "note", label: "Edit merchant note", "data-testid": "customer-edit-note-menu-item", onClick: () => onEdit("note") }, + { key: "moderation", label: "Edit moderation note", "data-testid": "customer-edit-moderation-menu-item", onClick: () => onEdit("moderation") }, + { key: "tax-identifiers", label: "Manage tax identifiers", "data-testid": "customer-manage-tax-identifiers-menu-item", onClick: () => onEdit("taxIdentifiers") }, + { key: "tax-exemptions", label: "Manage tax exemptions", "data-testid": "customer-manage-tax-exemptions-menu-item", onClick: () => onEdit("taxExemptions") }, + { key: "metadata", label: "View technical metadata", "data-testid": "customer-technical-metadata-menu-item", onClick: onTechnicalMetadata }, + { type: "divider" as const }, + { key: "delete", label: "Delete customer", danger: true, "data-testid": "customer-delete-menu-item", onClick: onDelete }, + ]; + + return ( + + + + + {customer.lifecycleStatus} + + + {meta} + + )} + actions={( + + + : null} + {truncated ? Showing {addresses.length} of {customer.addresses.totalCount} : null} + + ) : } />} + + ); +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-classification-section.tsx b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-classification-section.tsx new file mode 100644 index 000000000..cd16a31a5 --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-classification-section.tsx @@ -0,0 +1,47 @@ +import { Flex, Tag, Typography } from "antd"; +import type { ApiCustomer } from "@/graphql/types"; +import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { CustomerEditSection } from "../../../modals"; +import { useCustomerDetailsStyles } from "../customer-details-card.styles"; +import { enumLabel } from "../customer-details-utils"; + +export function CustomerClassificationSection({ customer, onEdit }: { customer: ApiCustomer; onEdit: (section: CustomerEditSection) => void }) { + const { styles } = useCustomerDetailsStyles(); + const groups = customer.groupMemberships.edges.map((edge) => edge.node); + const tags = customer.tagAssignments.edges.map((edge) => edge.node); + const segments = customer.segmentMemberships.edges.map((edge) => edge.node); + const items = [ + { key: "groups", label: "Edit customer groups", "data-testid": "customer-groups-edit-menu-item", onClick: () => onEdit("groups") }, + { key: "tags", label: "Edit customer tags", "data-testid": "customer-tags-edit-menu-item", onClick: () => onEdit("tags") }, + { key: "segments", label: "Edit manual segments", "data-testid": "customer-segments-edit-menu-item", onClick: () => onEdit("segments") }, + ]; + const truncated = customer.groupMemberships.totalCount > groups.length || customer.tagAssignments.totalCount > tags.length || customer.segmentMemberships.totalCount > segments.length; + + return ( + + onEdit("groups")} items={items} testId="customer-classification-actions" />} /> +
    + Groups + {groups.length ? {groups.map((membership) => ( + + {membership.group.name} · {enumLabel(membership.source)}{membership.isPrimary ? " · primary" : ""}{!membership.isActive ? " · Inactive" : ""} + + ))} : No groups} +
    +
    + Tags + {tags.length ? {tags.map((assignment) => {assignment.tag.name})} : No tags} +
    +
    + Segments + {segments.length ? {segments.map((membership) => ( + + {membership.segment.name} · {enumLabel(membership.source)}{!membership.isActive ? " · Inactive" : ""} + + ))} : No segments} +
    + {truncated ? Some memberships are not shown because this customer has more records than the details page limit. : null} +
    + ); +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-consents-section.tsx b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-consents-section.tsx new file mode 100644 index 000000000..c6eb6b8f9 --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-consents-section.tsx @@ -0,0 +1,48 @@ +import { Flex, Tag, Typography } from "antd"; +import { LuLock as LockOutlined, LuMail as MailOutlined, LuMessageSquare as SmsOutlined } from "react-icons/lu"; +import type { ApiCustomer } from "@/graphql/types"; +import { CustomerConsentChannel, CustomerConsentState } from "@/graphql/types"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { CustomerEditSection } from "../../../modals"; +import { useCustomerDetailsStyles } from "../customer-details-card.styles"; +import { compactParts, enumLabel, formatCustomerDate } from "../customer-details-utils"; + +const stateColor: Partial> = { + [CustomerConsentState.Subscribed]: "green", + [CustomerConsentState.Pending]: "gold", + [CustomerConsentState.Invalid]: "red", +}; + +export function CustomerConsentsSection({ customer, onEdit }: { customer: ApiCustomer; onEdit: (section: CustomerEditSection) => void }) { + const { styles } = useCustomerDetailsStyles(); + return ( + + onEdit("consents")} testId="customer-consents-actions" />} /> + {customer.consents.length ? customer.consents.map((consent) => { + const latest = consent.events.edges[0]?.node; + const channelLabel = enumLabel(consent.channel); + return ( +
    +
    {consent.channel === CustomerConsentChannel.Email ? : }
    + + + {channelLabel} + : undefined}>{enumLabel(consent.state).toUpperCase()} + + + {compactParts([consent.contactPoint, enumLabel(consent.optInLevel), `Updated ${formatCustomerDate(consent.updatedAt)}`])} + + {latest ? ( + + Latest transition: {enumLabel(latest.previousState) || "None"} → {enumLabel(latest.newState)} · {formatCustomerDate(latest.occurredAt)} + + ) : null} + +
    + ); + }) : } />} +
    + ); +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-order-activity-section.tsx b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-order-activity-section.tsx new file mode 100644 index 000000000..f3a8c1f44 --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-order-activity-section.tsx @@ -0,0 +1,57 @@ +import { Divider, Typography } from "antd"; +import { LuChartNoAxesCombined as ActivityOutlined } from "react-icons/lu"; +import type { ApiCustomer } from "@/graphql/types"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import { CopyableChip } from "@/ui-kit/copyable-chip"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useCustomerDetailsStyles } from "../customer-details-card.styles"; +import { formatCustomerDate, formatCustomerMoney, shortCustomerId } from "../customer-details-utils"; + +export function CustomerOrderActivitySection({ customer, currency }: { customer: ApiCustomer; currency: string | null }) { + const { styles } = useCustomerDetailsStyles(); + const statistics = customer.statistics; + const money = customer.monetaryStatistics.edges[0]?.node ?? null; + + return ( + + + {!statistics ? ( + } /> + ) : ( + <> +
    +
    + Order outcomes +
    +
    Completed{statistics.completedOrdersCount}
    +
    Cancelled{statistics.cancelledOrdersCount}
    +
    +
    +
    + Money breakdown +
    +
    Gross spent{formatCustomerMoney(money?.totalSpentMinor, currency)}
    +
    Refunded{formatCustomerMoney(money?.totalRefundedMinor, currency)}
    +
    +
    +
    + +
    +
    + First order + {formatCustomerDate(statistics.firstOrderAt)} + {statistics.firstOrderId ? : } +
    +
    + Last order + {formatCustomerDate(statistics.lastOrderAt)} + {statistics.lastOrderId ? : } +
    +
    Last checkout{formatCustomerDate(statistics.lastCheckoutAt)}
    +
    Last activity{formatCustomerDate(customer.lastActivityAt)}
    +
    + + )} +
    + ); +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-profile-section.tsx b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-profile-section.tsx new file mode 100644 index 000000000..e9e9edf4c --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-profile-section.tsx @@ -0,0 +1,40 @@ +import { Divider, Flex, Typography } from "antd"; +import type { ApiCustomer } from "@/graphql/types"; +import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { CustomerEditSection } from "../../../modals"; +import { useCustomerDetailsStyles } from "../customer-details-card.styles"; +import { compactParts, formatCustomerDate, formatLocale } from "../customer-details-utils"; + +export function CustomerProfileSection({ customer, onEdit }: { customer: ApiCustomer; onEdit: (section: CustomerEditSection) => void }) { + const { styles } = useCustomerDetailsStyles(); + const fullName = compactParts([customer.prefix, customer.firstName, customer.middleName, customer.lastName, customer.suffix], " "); + const personalMeta = compactParts([ + customer.dateOfBirth ? `Born ${formatCustomerDate(customer.dateOfBirth)}` : null, + customer.gender, + formatLocale(customer.preferredLocale), + ]); + const company = compactParts([customer.companyName, customer.jobTitle]); + const items = [ + { key: "profile", label: "Edit personal profile", "data-testid": "customer-profile-edit-menu-item", onClick: () => onEdit("profile") }, + { key: "contact", label: "Edit contact details", "data-testid": "customer-contact-edit-menu-item", onClick: () => onEdit("contact") }, + { key: "company", label: "Edit company", "data-testid": "customer-company-edit-menu-item", onClick: () => onEdit("company") }, + ]; + + return ( + + onEdit("profile")} items={items} testId="customer-profile-actions" />} /> + Personal + {fullName || "Unnamed customer"} + {personalMeta ? {personalMeta} : null} + + Company + {company ? ( + + {customer.companyName ? {customer.companyName} : null} + {customer.jobTitle ? {customer.jobTitle} : null} + + ) : No company information} + + ); +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-tax-section.tsx b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-tax-section.tsx new file mode 100644 index 000000000..85dba0f68 --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/customer-tax-section.tsx @@ -0,0 +1,55 @@ +import { Divider, Flex, Tag, Typography } from "antd"; +import { LuFileText as FileOutlined } from "react-icons/lu"; +import type { ApiCustomer } from "@/graphql/types"; +import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { CustomerEditSection } from "../../../modals"; +import { useCustomerDetailsStyles } from "../customer-details-card.styles"; +import { compactParts, customerTaxExemptionStatusColor, customerTaxIdentifierStatusColor, formatCustomerDate } from "../customer-details-utils"; + +export function CustomerTaxSection({ customer, onEdit }: { customer: ApiCustomer; onEdit: (section: CustomerEditSection) => void }) { + const { styles } = useCustomerDetailsStyles(); + const identifiers = customer.taxIdentifiers.edges.map((edge) => edge.node); + const exemptions = customer.taxExemptions.edges.map((edge) => edge.node); + const items = [ + { key: "identifiers", label: "Manage tax identifiers", "data-testid": "customer-tax-identifiers-menu-item", onClick: () => onEdit("taxIdentifiers") }, + { key: "exemptions", label: "Manage tax exemptions", "data-testid": "customer-tax-exemptions-menu-item", onClick: () => onEdit("taxExemptions") }, + ]; + + return ( + + onEdit("taxIdentifiers")} items={items} testId="customer-tax-actions" />} /> + Identifiers ({customer.taxIdentifiers.totalCount}) + {identifiers.length ? identifiers.map((item) => ( +
    + {item.status} + + {compactParts([item.identifierType, item.countryCode])} + {item.value} + {item.isPrimary ? Primary : null} + + {item.verifiedAt ? `Verified ${formatCustomerDate(item.verifiedAt)}` : item.validTo ? `Valid through ${formatCustomerDate(item.validTo)}` : "No expiry"} +
    + )) : No tax identifiers} + {customer.taxIdentifiers.totalCount > identifiers.length ? Showing {identifiers.length} of {customer.taxIdentifiers.totalCount} : null} + + Exemptions ({customer.taxExemptions.totalCount}) + {exemptions.length ? exemptions.map((item) => ( +
    + {item.status} + + {compactParts([item.code, compactParts([item.countryCode, item.regionCode], " / "), item.validTo ? `Valid through ${formatCustomerDate(item.validTo)}` : null])} + {item.reason || item.certificateFile ? ( + + {compactParts([item.reason, item.certificateFile?.originalName])} + {item.certificateFile ? : null} + + ) : null} + + +
    + )) : No tax exemptions} + {customer.taxExemptions.totalCount > exemptions.length ? Showing {exemptions.length} of {customer.taxExemptions.totalCount} : null} +
    + ); +} diff --git a/admin/src/domains/customers/all-customers/components/customer-details-card/sections/index.ts b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/index.ts new file mode 100644 index 000000000..b9caae9a0 --- /dev/null +++ b/admin/src/domains/customers/all-customers/components/customer-details-card/sections/index.ts @@ -0,0 +1,7 @@ +export * from "./customer-account-section"; +export * from "./customer-addresses-section"; +export * from "./customer-classification-section"; +export * from "./customer-consents-section"; +export * from "./customer-order-activity-section"; +export * from "./customer-profile-section"; +export * from "./customer-tax-section"; diff --git a/admin/src/domains/customers/all-customers/graphql/fragments.ts b/admin/src/domains/customers/all-customers/graphql/fragments.ts new file mode 100644 index 000000000..f7bb5bb4d --- /dev/null +++ b/admin/src/domains/customers/all-customers/graphql/fragments.ts @@ -0,0 +1,311 @@ +import { gql } from "@apollo/client"; + +export const CUSTOMER_LIST_FRAGMENT = gql` + fragment CustomerListFields on Customer { + id + revision + displayName + firstName + lastName + email + phoneE164 + lifecycleStatus + consents { + id + channel + state + contactPoint + } + defaultShippingAddress { + id + city + countryCode + } + statistics { + ordersCount + returnsCount + lastOrderAt + } + monetaryStatistics(first: 1, where: { currencyCode: { _eq: $currencyCode } }) { + edges { + node { + id + currencyCode + totalSpentMinor + averageOrderValueMinor + } + } + } + createdAt + updatedAt + } +`; + +export const CUSTOMER_DETAILS_FRAGMENT = gql` + fragment CustomerDetailsFields on Customer { + id + revision + displayName + firstName + lastName + email + phoneE164 + lifecycleStatus + defaultShippingAddress { + id + label + prefix + firstName + middleName + lastName + suffix + companyName + phoneE164 + address1 + address2 + city + regionName + regionCode + postalCode + countryCode + isDefaultShipping + isDefaultBilling + validationStatus + validatedAt + } + defaultBillingAddress { + id + label + prefix + firstName + middleName + lastName + suffix + companyName + phoneE164 + address1 + address2 + city + regionName + regionCode + postalCode + countryCode + isDefaultShipping + isDefaultBilling + validationStatus + validatedAt + } + createdAt + updatedAt + accountStatus + iamPrincipalId + emailVerified + phoneVerified + prefix + middleName + suffix + preferredLocale + dateOfBirth + gender + companyName + jobTitle + note + blockedReason + moderationNote + source + createdByUserId + lastActivityAt + redactedAt + deletedAt + mergedInto { + id + displayName + email + } + addresses(first: 50) { + edges { + node { + id + label + prefix + firstName + middleName + lastName + suffix + companyName + phoneE164 + address1 + address2 + city + regionName + regionCode + postalCode + countryCode + isDefaultShipping + isDefaultBilling + validationStatus + validatedAt + latitude + longitude + createdAt + updatedAt + } + } + totalCount + } + groupMemberships(first: 100) { + edges { + node { + id + group { + id + code + name + } + isPrimary + source + assignedById + assignedAt + expiresAt + isActive + } + } + totalCount + } + segmentMemberships(first: 250) { + edges { + node { + id + source + evaluatedAt + expiresAt + isActive + segment { + id + name + type + status + color + } + } + } + totalCount + } + tagAssignments(first: 250) { + edges { + node { + id + assignedById + assignedAt + tag { + id + name + } + } + } + totalCount + } + taxIdentifiers(first: 50) { + edges { + node { + id + identifierType + countryCode + value + normalizedValue + status + isPrimary + verifiedAt + validFrom + validTo + createdAt + updatedAt + } + } + totalCount + } + taxExemptions(first: 50) { + edges { + node { + id + code + countryCode + regionCode + reason + status + certificateFileId + certificateFile { + id + originalName + mimeType + url + } + validFrom + validTo + createdAt + updatedAt + } + } + totalCount + } + consents { + id + channel + state + optInLevel + contactPoint + source + sourceLocationId + sourceIp + userAgent + consentedAt + withdrawnAt + createdAt + updatedAt + events(first: 1, orderBy: [{ field: occurredAt, direction: desc }]) { + edges { + node { + id + previousState + newState + optInLevel + contactPoint + source + actorType + actorId + occurredAt + evidence + } + } + totalCount + } + } + statistics { + ordersCount + completedOrdersCount + cancelledOrdersCount + returnsCount + firstOrderId + firstOrderAt + lastOrderId + lastOrderAt + lastCheckoutAt + updatedAt + } + monetaryStatistics(first: 1, where: { currencyCode: { _eq: $currencyCode } }) { + edges { + node { + id + currencyCode + ordersCount + totalSpentMinor + totalRefundedMinor + netSpentMinor + averageOrderValueMinor + updatedAt + } + } + totalCount + } + } +`; diff --git a/admin/src/domains/customers/all-customers/graphql/index.ts b/admin/src/domains/customers/all-customers/graphql/index.ts new file mode 100644 index 000000000..176b6f88a --- /dev/null +++ b/admin/src/domains/customers/all-customers/graphql/index.ts @@ -0,0 +1,3 @@ +export * from "./fragments"; +export * from "./queries"; +export * from "./mutations"; diff --git a/admin/src/domains/customers/all-customers/graphql/mutations.ts b/admin/src/domains/customers/all-customers/graphql/mutations.ts new file mode 100644 index 000000000..6a911a3b9 --- /dev/null +++ b/admin/src/domains/customers/all-customers/graphql/mutations.ts @@ -0,0 +1,67 @@ +import { gql } from "@apollo/client"; + +export const CUSTOMER_CREATE_MUTATION = gql` + mutation CustomerCreate($input: CustomerCreateInput!) { + customersMutation { + customerCreate(input: $input) { + customer { + id + revision + displayName + } + userErrors { + code + field + message + } + } + } + } +`; + +export const CUSTOMER_UPDATE_MUTATION = gql` + mutation CustomerUpdate($customerId: ID!, $expectedRevision: Int!, $operations: CustomerUpdateInput!) { + customersMutation { + customerUpdate( + customerId: $customerId + expectedRevision: $expectedRevision + operations: $operations + ) { + customer { + id + revision + displayName + } + operationResults { + type + applied + errors { + code + field + message + } + } + userErrors { + code + field + message + } + } + } + } +`; + +export const CUSTOMER_DELETE_MUTATION = gql` + mutation CustomerDelete($input: CustomerDeleteInput!) { + customersMutation { + customerDelete(input: $input) { + deletedCustomerId + userErrors { + code + field + message + } + } + } + } +`; diff --git a/admin/src/domains/customers/all-customers/graphql/operation-types.ts b/admin/src/domains/customers/all-customers/graphql/operation-types.ts new file mode 100644 index 000000000..db7b70ec1 --- /dev/null +++ b/admin/src/domains/customers/all-customers/graphql/operation-types.ts @@ -0,0 +1,81 @@ +import type { + ApiCustomer, + ApiCustomerConnection, + ApiCustomerCreateInput, + ApiCustomerCreatePayload, + ApiCustomerDeleteInput, + ApiCustomerDeletePayload, + ApiCustomerOrderByInput, + ApiCustomersMutation, + ApiCustomersQuery, + ApiCustomerSegmentConnection, + ApiCustomerTagConnection, + ApiCustomerGroupConnection, + ApiCustomerUpdateInput, + ApiCustomerUpdatePayload, + ApiCustomerWhereInput, +} from "@/graphql/types"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; + +export interface CustomersQueryData { + customersQuery: Pick & { + customers: ApiCustomerConnection; + }; +} + +export interface CustomersQueryVariables extends RelayCursorPaginationVariables { + where?: ApiCustomerWhereInput | null; + orderBy?: ApiCustomerOrderByInput[] | null; + currencyCode?: string | null; +} + +export interface CustomerQueryData { + customersQuery: Pick & { + customer: ApiCustomer | null; + }; +} + +export interface CustomerQueryVariables { + id: string; + currencyCode?: string | null; +} + +export interface CustomerEditorContextQueryData { + customersQuery: Pick & { + customerSegments: ApiCustomerSegmentConnection; + customerTags: ApiCustomerTagConnection; + customerGroups: ApiCustomerGroupConnection; + }; +} + +export interface CustomerCreateMutationData { + customersMutation: Pick & { + customerCreate: ApiCustomerCreatePayload; + }; +} + +export interface CustomerCreateMutationVariables { + input: ApiCustomerCreateInput; +} + +export interface CustomerUpdateMutationData { + customersMutation: Pick & { + customerUpdate: ApiCustomerUpdatePayload; + }; +} + +export interface CustomerUpdateMutationVariables { + customerId: string; + expectedRevision: number; + operations: ApiCustomerUpdateInput; +} + +export interface CustomerDeleteMutationData { + customersMutation: Pick & { + customerDelete: ApiCustomerDeletePayload; + }; +} + +export interface CustomerDeleteMutationVariables { + input: ApiCustomerDeleteInput; +} diff --git a/admin/src/domains/customers/all-customers/graphql/queries.ts b/admin/src/domains/customers/all-customers/graphql/queries.ts new file mode 100644 index 000000000..71aa8f4b5 --- /dev/null +++ b/admin/src/domains/customers/all-customers/graphql/queries.ts @@ -0,0 +1,87 @@ +import { gql } from "@apollo/client"; +import { CUSTOMER_DETAILS_FRAGMENT, CUSTOMER_LIST_FRAGMENT } from "./fragments"; + +export const CUSTOMERS_QUERY = gql` + query Customers( + $first: Int + $after: String + $last: Int + $before: String + $where: CustomerWhereInput + $orderBy: [CustomerOrderByInput!] + $currencyCode: String + ) { + customersQuery { + customers( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...CustomerListFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + ${CUSTOMER_LIST_FRAGMENT} +`; + +export const CUSTOMER_QUERY = gql` + query Customer($id: ID!, $currencyCode: String) { + customersQuery { + customer(id: $id) { + ...CustomerDetailsFields + } + } + } + ${CUSTOMER_DETAILS_FRAGMENT} +`; + +export const CUSTOMER_EDITOR_CONTEXT_QUERY = gql` + query CustomerEditorContext { + customersQuery { + customerSegments(first: 250, where: { type: { _eq: MANUAL }, status: { _eq: ACTIVE } }) { + edges { + node { + id + name + } + } + totalCount + } + customerTags(first: 250) { + edges { + node { + id + name + } + } + totalCount + } + customerGroups(first: 250, where: { isActive: { _eq: true } }) { + edges { + node { + id + code + name + isDefault + } + } + totalCount + } + } + } +`; diff --git a/admin/src/domains/customers/all-customers/hooks/index.ts b/admin/src/domains/customers/all-customers/hooks/index.ts new file mode 100644 index 000000000..6b233aa2b --- /dev/null +++ b/admin/src/domains/customers/all-customers/hooks/index.ts @@ -0,0 +1,6 @@ +export * from "./use-customer"; +export * from "./use-customer-editor-context"; +export * from "./use-customers"; +export * from "./use-create-customer"; +export * from "./use-update-customer"; +export * from "./use-delete-customer"; diff --git a/admin/src/domains/customers/all-customers/hooks/use-create-customer.ts b/admin/src/domains/customers/all-customers/hooks/use-create-customer.ts new file mode 100644 index 000000000..5c76f9cf1 --- /dev/null +++ b/admin/src/domains/customers/all-customers/hooks/use-create-customer.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiCustomerCreateInput, ApiGenericUserError } from "@/graphql/types"; +import { CUSTOMER_CREATE_MUTATION, CUSTOMERS_QUERY } from "../graphql"; +import type { + CustomerCreateMutationData, + CustomerCreateMutationVariables, +} from "../graphql/operation-types"; + +export function useCreateCustomer() { + const [mutate, { loading, error, reset }] = useMutation< + CustomerCreateMutationData, + CustomerCreateMutationVariables + >(CUSTOMER_CREATE_MUTATION); + + const createCustomer = useCallback(async (input: ApiCustomerCreateInput) => { + try { + const result = await mutate({ variables: { input }, refetchQueries: [CUSTOMERS_QUERY] }); + const payload = result.data?.customersMutation.customerCreate; + return { + customer: payload?.customer ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Unable to create customer"; + return { + customer: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] as ApiGenericUserError[], + }; + } + }, [mutate]); + + return { createCustomer, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/customers/all-customers/hooks/use-customer-editor-context.ts b/admin/src/domains/customers/all-customers/hooks/use-customer-editor-context.ts new file mode 100644 index 000000000..8c5b9eb89 --- /dev/null +++ b/admin/src/domains/customers/all-customers/hooks/use-customer-editor-context.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { CUSTOMER_EDITOR_CONTEXT_QUERY } from "../graphql"; +import type { CustomerEditorContextQueryData } from "../graphql/operation-types"; + +export function useCustomerEditorContext() { + const { data, previousData, loading, error, refetch } = useQuery< + CustomerEditorContextQueryData + >(CUSTOMER_EDITOR_CONTEXT_QUERY, { fetchPolicy: "cache-and-network" }); + const customersQuery = (data ?? previousData)?.customersQuery; + + return { + context: customersQuery + ? { + segments: customersQuery.customerSegments.edges.map((edge) => edge.node), + tags: customersQuery.customerTags.edges.map((edge) => edge.node), + groups: customersQuery.customerGroups.edges.map((edge) => edge.node), + truncated: { + segments: customersQuery.customerSegments.totalCount > customersQuery.customerSegments.edges.length, + tags: customersQuery.customerTags.totalCount > customersQuery.customerTags.edges.length, + groups: customersQuery.customerGroups.totalCount > customersQuery.customerGroups.edges.length, + }, + } + : null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/customers/all-customers/hooks/use-customer.ts b/admin/src/domains/customers/all-customers/hooks/use-customer.ts new file mode 100644 index 000000000..8111a3bd0 --- /dev/null +++ b/admin/src/domains/customers/all-customers/hooks/use-customer.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { useDefaultCurrency } from "@/domains/workspace"; +import { CUSTOMER_QUERY } from "../graphql"; +import type { + CustomerQueryData, + CustomerQueryVariables, +} from "../graphql/operation-types"; + +export function useCustomer(id?: string) { + const currencyCode = useDefaultCurrency(); + const { data, previousData, loading, error, refetch } = useQuery< + CustomerQueryData, + CustomerQueryVariables + >(CUSTOMER_QUERY, { + variables: { id: id ?? "", currencyCode }, + skip: !id, + fetchPolicy: "cache-and-network", + }); + + return { + customer: (data ?? previousData)?.customersQuery.customer ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/customers/all-customers/hooks/use-customers.ts b/admin/src/domains/customers/all-customers/hooks/use-customers.ts new file mode 100644 index 000000000..3946565fe --- /dev/null +++ b/admin/src/domains/customers/all-customers/hooks/use-customers.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { useDefaultCurrency } from "@/domains/workspace"; +import type { + ApiCustomer, + ApiCustomerConnection, + ApiPageInfo, +} from "@/graphql/types"; +import { CUSTOMERS_QUERY } from "../graphql"; +import type { + CustomersQueryData, + CustomersQueryVariables, +} from "../graphql/operation-types"; + +export interface UseCustomersReturn { + customers: ApiCustomer[]; + connection: ApiCustomerConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useCustomers(variables: CustomersQueryVariables): UseCustomersReturn { + const defaultCurrency = useDefaultCurrency(); + const { data, previousData, loading, error, refetch } = useQuery< + CustomersQueryData, + CustomersQueryVariables + >(CUSTOMERS_QUERY, { + variables: { ...variables, currencyCode: variables.currencyCode ?? defaultCurrency }, + fetchPolicy: "cache-and-network", + }); + const connection = (data ?? previousData)?.customersQuery.customers ?? null; + + return { + customers: connection?.edges.map((edge) => edge.node) ?? [], + connection, + totalCount: connection?.totalCount ?? 0, + pageInfo: connection?.pageInfo ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/customers/all-customers/hooks/use-delete-customer.ts b/admin/src/domains/customers/all-customers/hooks/use-delete-customer.ts new file mode 100644 index 000000000..2b56868f1 --- /dev/null +++ b/admin/src/domains/customers/all-customers/hooks/use-delete-customer.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiCustomerDeleteInput, ApiGenericUserError } from "@/graphql/types"; +import { CUSTOMER_DELETE_MUTATION, CUSTOMERS_QUERY } from "../graphql"; +import type { + CustomerDeleteMutationData, + CustomerDeleteMutationVariables, +} from "../graphql/operation-types"; + +export function useDeleteCustomer() { + const [mutate, { loading, error, reset }] = useMutation< + CustomerDeleteMutationData, + CustomerDeleteMutationVariables + >(CUSTOMER_DELETE_MUTATION); + + const deleteCustomer = useCallback(async (input: ApiCustomerDeleteInput) => { + try { + const result = await mutate({ + variables: { input }, + refetchQueries: [CUSTOMERS_QUERY], + }); + const payload = result.data?.customersMutation.customerDelete; + return { + deletedCustomerId: payload?.deletedCustomerId ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Unable to delete customer"; + return { + deletedCustomerId: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] as ApiGenericUserError[], + }; + } + }, [mutate]); + + return { deleteCustomer, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/customers/all-customers/hooks/use-update-customer.ts b/admin/src/domains/customers/all-customers/hooks/use-update-customer.ts new file mode 100644 index 000000000..016f2ded8 --- /dev/null +++ b/admin/src/domains/customers/all-customers/hooks/use-update-customer.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiCustomerUpdateInput, ApiGenericUserError } from "@/graphql/types"; +import { CUSTOMER_UPDATE_MUTATION, CUSTOMERS_QUERY } from "../graphql"; +import type { + CustomerUpdateMutationData, + CustomerUpdateMutationVariables, +} from "../graphql/operation-types"; + +export function useUpdateCustomer() { + const [mutate, { loading, error, reset }] = useMutation< + CustomerUpdateMutationData, + CustomerUpdateMutationVariables + >(CUSTOMER_UPDATE_MUTATION); + + const updateCustomer = useCallback(async ( + customerId: string, + expectedRevision: number, + operations: ApiCustomerUpdateInput, + ) => { + try { + const result = await mutate({ + variables: { customerId, expectedRevision, operations }, + refetchQueries: [CUSTOMERS_QUERY], + }); + const payload = result.data?.customersMutation.customerUpdate; + const operationErrors = payload?.operationResults.flatMap((item) => item.errors) ?? []; + return { + customer: payload?.customer ?? null, + userErrors: [...(payload?.userErrors ?? []), ...operationErrors], + }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Unable to update customer"; + return { + customer: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] as ApiGenericUserError[], + }; + } + }, [mutate]); + + return { updateCustomer, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/customers/all-customers/mappers/customer-form.mapper.ts b/admin/src/domains/customers/all-customers/mappers/customer-form.mapper.ts new file mode 100644 index 000000000..b8264bfe1 --- /dev/null +++ b/admin/src/domains/customers/all-customers/mappers/customer-form.mapper.ts @@ -0,0 +1,134 @@ +import type { FieldPath } from "react-hook-form"; +import type { + ApiCustomer, + ApiCustomerAddressCreateOperationInput, + ApiCustomerAddressUpdateOperationInput, + ApiCustomerCreateInput, + ApiCustomerUpdateInput, + ApiGenericUserError, +} from "@/graphql/types"; +import { CustomerConsentChannel, CustomerConsentOptInLevel } from "@/graphql/types"; +import type { CustomerFormValues } from "../modals/customer-modal/schema"; + +function toAddress(values: CustomerFormValues): ApiCustomerAddressCreateOperationInput | null { + const address = values.defaultAddress; + const hasAddress = Object.values(address).some((value) => value.trim().length > 0); + if (!hasAddress) return null; + + return { + address1: address.address1.trim(), + address2: address.address2.trim() || null, + city: address.city.trim(), + regionName: address.province.trim() || null, + postalCode: address.postalCode.trim() || null, + countryCode: address.countryCode, + isDefaultShipping: true, + }; +} + +export function buildCustomerCreateInput(values: CustomerFormValues): ApiCustomerCreateInput { + return { + firstName: values.firstName.trim(), + lastName: values.lastName.trim(), + email: values.email.trim().toLowerCase(), + phoneE164: values.phone.trim() || null, + preferredLocale: values.locale, + note: values.note.trim() || null, + moderationNote: values.moderationNote.trim() || null, + }; +} + +export function buildCustomerUpdateInput( + values: CustomerFormValues, + customer?: ApiCustomer | null, +): ApiCustomerUpdateInput { + const address = toAddress(values); + const existingAddress = customer?.defaultShippingAddress; + const addressUpdate: ApiCustomerAddressUpdateOperationInput | null = address && existingAddress + ? { + addressId: existingAddress.id, + operations: { + address1: address.address1, + address2: address.address2, + city: address.city, + regionName: address.regionName, + postalCode: address.postalCode, + countryCode: address.countryCode, + }, + } + : null; + const consents = [ + { + channel: CustomerConsentChannel.Email, + state: values.emailMarketingState, + contactPoint: values.email.trim().toLowerCase(), + optInLevel: CustomerConsentOptInLevel.Unknown, + }, + ...(values.phone.trim() + ? [{ + channel: CustomerConsentChannel.Sms, + state: values.smsMarketingState, + contactPoint: values.phone.trim(), + optInLevel: CustomerConsentOptInLevel.Unknown, + }] + : []), + ]; + + return { + profile: { + firstName: values.firstName.trim(), + lastName: values.lastName.trim(), + preferredLocale: values.locale, + }, + contact: { + email: values.email.trim().toLowerCase(), + phoneE164: values.phone.trim() || null, + }, + status: { + status: values.status, + blockedReason: values.status === "BLOCKED" ? values.blockedReason.trim() : null, + }, + note: { note: values.note.trim() || null }, + moderation: { moderationNote: values.moderationNote.trim() || null }, + segments: { segmentIds: values.segmentIds }, + tags: { tagIds: values.tagIds }, + consents: { set: consents }, + addresses: address + ? existingAddress + ? { update: addressUpdate ? [addressUpdate] : [] } + : { create: [address] } + : existingAddress + ? { deleteIds: [existingAddress.id], defaultShippingAddressId: null } + : undefined, + }; +} + +const fieldMap: Record> = { + firstName: "firstName", + lastName: "lastName", + email: "email", + phoneE164: "phone", + "contact.email": "email", + "contact.phoneE164": "phone", + "profile.firstName": "firstName", + "profile.lastName": "lastName", + "profile.preferredLocale": "locale", + "status.status": "status", + "status.blockedReason": "blockedReason", + "segments.segmentIds": "segmentIds", + "tags.tagIds": "tagIds", + "addresses.create.address1": "defaultAddress.address1", + "addresses.create.city": "defaultAddress.city", + "addresses.create.postalCode": "defaultAddress.postalCode", + "addresses.create.countryCode": "defaultAddress.countryCode", +}; + +export function mapCustomerUserErrors(errors: ApiGenericUserError[]) { + return errors.map((error) => { + const path = error.field?.join(".") ?? null; + return { + field: path ? fieldMap[path] ?? null : null, + message: error.message, + }; + }); +} diff --git a/admin/src/domains/customers/all-customers/mappers/index.ts b/admin/src/domains/customers/all-customers/mappers/index.ts new file mode 100644 index 000000000..b245aecb7 --- /dev/null +++ b/admin/src/domains/customers/all-customers/mappers/index.ts @@ -0,0 +1 @@ +export * from "./customer-form.mapper"; diff --git a/admin/src/domains/customers/all-customers/modals.ts b/admin/src/domains/customers/all-customers/modals.ts new file mode 100644 index 000000000..f8bf13349 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals.ts @@ -0,0 +1,76 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; +import type { CustomerAddressDraft, CustomerTaxExemptionDraft, CustomerTaxIdentifierDraft } from "./modals/shared/customer-drafts"; + +export const CUSTOMER_MODAL_TYPE = "customer"; +export const CUSTOMER_CREATE_MODAL_TYPE = "customer-create"; +export const CUSTOMER_EDIT_PROFILE_MODAL_TYPE = "customer-edit-profile"; +export const CUSTOMER_EDIT_CONTACT_MODAL_TYPE = "customer-edit-contact"; +export const CUSTOMER_EDIT_COMPANY_MODAL_TYPE = "customer-edit-company"; +export const CUSTOMER_MANAGE_ADDRESSES_MODAL_TYPE = "customer-manage-addresses"; +export const CUSTOMER_EDIT_ADDRESS_MODAL_TYPE = "customer-edit-address"; +export const CUSTOMER_EDIT_CONSENTS_MODAL_TYPE = "customer-edit-consents"; +export const CUSTOMER_EDIT_GROUPS_MODAL_TYPE = "customer-edit-groups"; +export const CUSTOMER_EDIT_TAGS_MODAL_TYPE = "customer-edit-tags"; +export const CUSTOMER_EDIT_SEGMENTS_MODAL_TYPE = "customer-edit-segments"; +export const CUSTOMER_EDIT_STATUS_MODAL_TYPE = "customer-edit-status"; +export const CUSTOMER_EDIT_NOTE_MODAL_TYPE = "customer-edit-note"; +export const CUSTOMER_EDIT_MODERATION_MODAL_TYPE = "customer-edit-moderation"; +export const CUSTOMER_MANAGE_TAX_IDENTIFIERS_MODAL_TYPE = "customer-manage-tax-identifiers"; +export const CUSTOMER_EDIT_TAX_IDENTIFIER_MODAL_TYPE = "customer-edit-tax-identifier"; +export const CUSTOMER_MANAGE_TAX_EXEMPTIONS_MODAL_TYPE = "customer-manage-tax-exemptions"; +export const CUSTOMER_EDIT_TAX_EXEMPTION_MODAL_TYPE = "customer-edit-tax-exemption"; +export const CUSTOMER_TECHNICAL_METADATA_MODAL_TYPE = "customer-technical-metadata"; + +export interface CustomerModalPayload extends IModalStackPayload { entityId: string; onSaved?: () => Promise | unknown; } +export interface CustomerCreateModalPayload extends IModalStackPayload { onCreated?: (customerId: string) => Promise | unknown; } +export interface CustomerSectionModalPayload extends IModalStackPayload { entityId: string; onSaved?: () => Promise | unknown; } +export interface CustomerAddressItemModalPayload extends IModalStackPayload { item: CustomerAddressDraft; title: string; onApply: (item: CustomerAddressDraft) => void; } +export interface CustomerTaxIdentifierItemModalPayload extends IModalStackPayload { item: CustomerTaxIdentifierDraft; title: string; onApply: (item: CustomerTaxIdentifierDraft) => void; } +export interface CustomerTaxExemptionItemModalPayload extends IModalStackPayload { item: CustomerTaxExemptionDraft; title: string; onApply: (item: CustomerTaxExemptionDraft) => void; } + +export type CustomerEditSection = "profile" | "contact" | "company" | "addresses" | "consents" | "groups" | "tags" | "segments" | "status" | "note" | "moderation" | "taxIdentifiers" | "taxExemptions"; + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [CUSTOMER_MODAL_TYPE]: CustomerModalPayload; + [CUSTOMER_CREATE_MODAL_TYPE]: CustomerCreateModalPayload; + [CUSTOMER_EDIT_PROFILE_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_CONTACT_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_COMPANY_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_MANAGE_ADDRESSES_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_ADDRESS_MODAL_TYPE]: CustomerAddressItemModalPayload; + [CUSTOMER_EDIT_CONSENTS_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_GROUPS_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_TAGS_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_SEGMENTS_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_STATUS_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_NOTE_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_MODERATION_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_MANAGE_TAX_IDENTIFIERS_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_TAX_IDENTIFIER_MODAL_TYPE]: CustomerTaxIdentifierItemModalPayload; + [CUSTOMER_MANAGE_TAX_EXEMPTIONS_MODAL_TYPE]: CustomerSectionModalPayload; + [CUSTOMER_EDIT_TAX_EXEMPTION_MODAL_TYPE]: CustomerTaxExemptionItemModalPayload; + [CUSTOMER_TECHNICAL_METADATA_MODAL_TYPE]: CustomerSectionModalPayload; + } +} + +export const useCustomerModal = createModalStackHook(CUSTOMER_MODAL_TYPE); +export const useCustomerCreateModal = createModalStackHook(CUSTOMER_CREATE_MODAL_TYPE); +export const useCustomerEditProfileModal = createModalStackHook(CUSTOMER_EDIT_PROFILE_MODAL_TYPE); +export const useCustomerEditContactModal = createModalStackHook(CUSTOMER_EDIT_CONTACT_MODAL_TYPE); +export const useCustomerEditCompanyModal = createModalStackHook(CUSTOMER_EDIT_COMPANY_MODAL_TYPE); +export const useCustomerManageAddressesModal = createModalStackHook(CUSTOMER_MANAGE_ADDRESSES_MODAL_TYPE); +export const useCustomerEditAddressModal = createModalStackHook(CUSTOMER_EDIT_ADDRESS_MODAL_TYPE); +export const useCustomerEditConsentsModal = createModalStackHook(CUSTOMER_EDIT_CONSENTS_MODAL_TYPE); +export const useCustomerEditGroupsModal = createModalStackHook(CUSTOMER_EDIT_GROUPS_MODAL_TYPE); +export const useCustomerEditTagsModal = createModalStackHook(CUSTOMER_EDIT_TAGS_MODAL_TYPE); +export const useCustomerEditSegmentsModal = createModalStackHook(CUSTOMER_EDIT_SEGMENTS_MODAL_TYPE); +export const useCustomerEditStatusModal = createModalStackHook(CUSTOMER_EDIT_STATUS_MODAL_TYPE); +export const useCustomerEditNoteModal = createModalStackHook(CUSTOMER_EDIT_NOTE_MODAL_TYPE); +export const useCustomerEditModerationModal = createModalStackHook(CUSTOMER_EDIT_MODERATION_MODAL_TYPE); +export const useCustomerManageTaxIdentifiersModal = createModalStackHook(CUSTOMER_MANAGE_TAX_IDENTIFIERS_MODAL_TYPE); +export const useCustomerEditTaxIdentifierModal = createModalStackHook(CUSTOMER_EDIT_TAX_IDENTIFIER_MODAL_TYPE); +export const useCustomerManageTaxExemptionsModal = createModalStackHook(CUSTOMER_MANAGE_TAX_EXEMPTIONS_MODAL_TYPE); +export const useCustomerEditTaxExemptionModal = createModalStackHook(CUSTOMER_EDIT_TAX_EXEMPTION_MODAL_TYPE); +export const useCustomerTechnicalMetadataModal = createModalStackHook(CUSTOMER_TECHNICAL_METADATA_MODAL_TYPE); diff --git a/admin/src/domains/customers/all-customers/modals/create-customer-modal/create-customer-modal.tsx b/admin/src/domains/customers/all-customers/modals/create-customer-modal/create-customer-modal.tsx new file mode 100644 index 000000000..db46ff790 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/create-customer-modal/create-customer-modal.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, App, Input, Select } from "antd"; +import { createStyles } from "antd-style"; +import { shopLocales } from "@/defs/localization"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useCreateCustomer } from "../../hooks"; +import type { CustomerCreateModalPayload } from "../../modals"; +import { createCustomerSchema, type CreateCustomerFormValues } from "./schema"; + +const useStyles = createStyles(({ token }) => ({ + container: { display: "flex", flexDirection: "column", gap: token.padding }, + fields: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: token.padding }, + full: { gridColumn: "1 / -1" }, + label: { display: "block", marginBottom: 6, fontWeight: 500 }, + error: { color: token.colorError, fontSize: 12, marginTop: 4 }, +})); + +const DEFAULT_VALUES: CreateCustomerFormValues = { + firstName: "", + lastName: "", + email: "", + phoneE164: "", + preferredLocale: "en", +}; + +export function CreateCustomerModal() { + const { styles } = useStyles(); + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const typedPayload = payload as CustomerCreateModalPayload; + const { createCustomer, loading, error } = useCreateCustomer(); + const methods = useForm({ + resolver: zodResolver(createCustomerSchema), + defaultValues: DEFAULT_VALUES, + mode: "onChange", + }); + const { control, handleSubmit, setError, formState: { errors, isDirty, isValid } } = methods; + + useEffect(() => setDirty(isDirty), [isDirty, setDirty]); + + const submit = useCallback(async (values: CreateCustomerFormValues) => { + const result = await createCustomer({ + firstName: values.firstName.trim(), + lastName: values.lastName.trim(), + email: values.email.trim().toLowerCase(), + phoneE164: values.phoneE164.trim() || null, + preferredLocale: values.preferredLocale, + }); + if (!result.customer || result.userErrors.length > 0) { + for (const item of result.userErrors) { + const field = item.field?.at(-1); + if (field === "firstName" || field === "lastName" || field === "email" || field === "phoneE164" || field === "preferredLocale") { + setError(field, { message: item.message }); + } + } + message.error(result.userErrors[0]?.message ?? "Unable to create customer"); + return; + } + await typedPayload.onCreated?.(result.customer.id); + setDirty(false); + message.success("Customer created"); + forcePop(); + }, [createCustomer, forcePop, message, setDirty, setError, typedPayload]); + + return ( + + } + > +
    + {error ? : null} + + +
    +
    + + } /> + {errors.firstName ?
    {errors.firstName.message}
    : null} +
    +
    + + } /> + {errors.lastName ?
    {errors.lastName.message}
    : null} +
    +
    + + } /> + {errors.email ?
    {errors.email.message}
    : null} +
    +
    + + } /> + {errors.phoneE164 ?
    {errors.phoneE164.message}
    : null} +
    +
    + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> +
    +
    +
    } />
    +
    } />
    + } /> + } /> + } /> + } /> + } />
    } />
    : null} + ; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-company-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-company-modal/index.ts new file mode 100644 index 000000000..3e2d97bdb --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-company-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerCompanyModal } from "./edit-customer-company-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-consents-modal/edit-customer-consents-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-consents-modal/edit-customer-consents-modal.tsx new file mode 100644 index 000000000..f30eba049 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-consents-modal/edit-customer-consents-modal.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Flex, Input, Select, Tag, Typography } from "antd"; +import type { ApiCustomer } from "@/graphql/types"; +import { CustomerConsentAdminState, CustomerConsentChannel, CustomerConsentOptInLevel, CustomerConsentState } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { enumLabel, formatCustomerDate } from "../../components/customer-details-card/customer-details-utils"; +import { CustomerFormField, CustomerSectionModalFrame, customerConsentsSchema, type CustomerConsentsValues, sameDraft, useCustomerSectionModal } from "../shared"; + +function defaults(customer: ApiCustomer): CustomerConsentsValues { + const existing = new Map(customer.consents.map((consent) => [consent.channel, consent])); + return { channels: Object.values(CustomerConsentChannel).map((channel) => { + const consent = existing.get(channel); + const regular = consent && ![CustomerConsentState.Invalid, CustomerConsentState.Redacted].includes(consent.state); + const fallback = channel === CustomerConsentChannel.Email ? customer.email ?? "" : [CustomerConsentChannel.Sms, CustomerConsentChannel.Whatsapp].includes(channel) ? customer.phoneE164 ?? "" : ""; + return { + channel, + enabled: Boolean(regular), + locked: consent?.state === CustomerConsentState.Redacted, + existingState: consent?.state ?? null, + state: regular ? consent.state as unknown as CustomerConsentAdminState : null, + optInLevel: consent?.optInLevel ?? CustomerConsentOptInLevel.Unknown, + contactPoint: consent?.contactPoint ?? fallback, + }; + }) }; +} + +export function EditCustomerConsentsModal() { + const state = useCustomerSectionModal("Marketing consents updated"); const initialized = useRef(false); const lastReload = useRef(-1); const original = useRef(null); + const form = useForm({ resolver: zodResolver(customerConsentsSchema), defaultValues: { channels: [] }, mode: "onChange" }); + const { control, handleSubmit, reset, setError, setValue, watch, formState: { errors, isDirty, isValid } } = form; const { fields } = useFieldArray({ control, name: "channels" }); const values = watch("channels"); + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; const next = defaults(state.customer); original.current = next; reset(next); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + const submit = handleSubmit(async (formValues) => { + const changed = formValues.channels.filter((row, index) => row.enabled && row.state && !sameDraft(row, original.current?.channels[index])); + await state.save({ consents: { set: changed.map((row) => ({ channel: row.channel, state: row.state!, optInLevel: row.optInLevel, contactPoint: row.contactPoint.trim() })) } }, { "consents.set": "channels" }, setError); + }); + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}> + {state.customer ? {fields.map((field, index) => { + const current = state.customer?.consents.find((consent) => consent.channel === field.channel); const row = values[index]; const rowErrors = errors.channels?.[index]; + return Current: {current ? `${enumLabel(current.state)} · ${enumLabel(current.optInLevel)} · Updated ${formatCustomerDate(current.updatedAt)}` : "No record"}} /> + {row?.locked ? This consent record was redacted and cannot be changed. : !row?.enabled ? :
    + ({ value: item, label: enumLabel(item) }))} style={{ width: "100%" }} />} /> + } /> +
    } + {current?.state === CustomerConsentState.Invalid ? Existing state remains Invalid until an explicit transition is saved : null} +
    ; + })}
    : null} +
    ; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-consents-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-consents-modal/index.ts new file mode 100644 index 000000000..f09bf6b83 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-consents-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerConsentsModal } from "./edit-customer-consents-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-contact-modal/edit-customer-contact-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-contact-modal/edit-customer-contact-modal.tsx new file mode 100644 index 000000000..d7917ecf8 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-contact-modal/edit-customer-contact-modal.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, Input, Typography } from "antd"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { cleanOptional, CustomerFormField, CustomerSectionModalFrame, customerContactSchema, type CustomerContactValues, useCustomerSectionModal } from "../shared"; + +export function EditCustomerContactModal() { + const state = useCustomerSectionModal("Contact details updated"); + const initialized = useRef(false); const lastReload = useRef(-1); + const form = useForm({ resolver: zodResolver(customerContactSchema), defaultValues: { email: "", phoneE164: "" }, mode: "onChange" }); + const { control, handleSubmit, reset, setError, formState: { errors, isDirty, isValid } } = form; + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; reset({ email: state.customer.email ?? "", phoneE164: state.customer.phoneE164 ?? "" }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + const submit = handleSubmit(async (values) => { await state.save({ contact: { email: cleanOptional(values.email.toLowerCase()), phoneE164: cleanOptional(values.phoneE164) } }, { "contact.email": "email", "contact.phoneE164": "phoneE164" }, setError); }); + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}> + {state.customer ? + } /> +
    } />
    + + {state.customer.consents.some((consent) => consent.contactPoint === state.customer?.email || consent.contactPoint === state.customer?.phoneE164) ? Marketing consent contact points are managed separately and will not be changed. : null} +
    : null} +
    ; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-contact-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-contact-modal/index.ts new file mode 100644 index 000000000..44ee8df18 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-contact-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerContactModal } from "./edit-customer-contact-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-groups-modal/edit-customer-groups-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-groups-modal/edit-customer-groups-modal.tsx new file mode 100644 index 000000000..61f8c74fa --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-groups-modal/edit-customer-groups-modal.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, Select } from "antd"; +import { CustomerAssignmentSource } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useCustomerEditorContext } from "../../hooks"; +import { CustomerFormField, CustomerSectionModalFrame, customerGroupsSchema, type CustomerGroupsValues, useCustomerSectionModal } from "../shared"; + +export function EditCustomerGroupsModal() { + const state = useCustomerSectionModal("Customer groups updated"); const context = useCustomerEditorContext(); const initialized = useRef(false); const lastReload = useRef(-1); + const form = useForm({ resolver: zodResolver(customerGroupsSchema), defaultValues: { groupIds: [], primaryGroupId: null }, mode: "onChange" }); + const { control, handleSubmit, reset, setError, watch, setValue, formState: { errors, isDirty, isValid } } = form; const groupIds = watch("groupIds"); + const truncated = Boolean(state.customer && state.customer.groupMemberships.totalCount > state.customer.groupMemberships.edges.length) || Boolean(context.context?.truncated.groups); + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; const memberships = state.customer.groupMemberships.edges.map((edge) => edge.node); const manual = memberships.filter((item) => item.source === CustomerAssignmentSource.Manual); reset({ groupIds: manual.map((item) => item.group.id), primaryGroupId: manual.find((item) => item.isPrimary)?.group.id ?? null }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + const submit = handleSubmit(async (values) => { await state.save({ groups: { memberships: values.groupIds.map((groupId) => ({ groupId, isPrimary: groupId === values.primaryGroupId })) } }, { "groups.memberships": "groupIds" }, setError); }); + return void submit()} onClose={state.pop} queryLoading={state.queryLoading || context.loading} hasCustomer={Boolean(state.customer)} error={state.error ?? context.error?.message ?? null} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}> + {state.customer ? <><>{truncated ? : null} groupIds.includes(item.id)).map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => field.onChange(value ?? null)} style={{ width: "100%" }} />} />
    : null} + ; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-groups-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-groups-modal/index.ts new file mode 100644 index 000000000..5a764756c --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-groups-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerGroupsModal } from "./edit-customer-groups-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-moderation-modal/edit-customer-moderation-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-moderation-modal/edit-customer-moderation-modal.tsx new file mode 100644 index 000000000..235b226af --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-moderation-modal/edit-customer-moderation-modal.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "antd"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { cleanOptional, CustomerFormField, CustomerSectionModalFrame, customerModerationSchema, type CustomerModerationValues, useCustomerSectionModal } from "../shared"; + +export function EditCustomerModerationModal() { + const state = useCustomerSectionModal("Moderation note updated"); const initialized = useRef(false); const lastReload = useRef(-1); + const form = useForm({ resolver: zodResolver(customerModerationSchema), defaultValues: { moderationNote: "" }, mode: "onChange" }); + const { control, handleSubmit, reset, setError, formState: { errors, isDirty, isValid } } = form; + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; reset({ moderationNote: state.customer.moderationNote ?? "" }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + const submit = handleSubmit(async (values) => { await state.save({ moderation: { moderationNote: cleanOptional(values.moderationNote) } }, { "moderation.moderationNote": "moderationNote" }, setError); }); + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}>{state.customer ? } /> : null}; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-moderation-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-moderation-modal/index.ts new file mode 100644 index 000000000..333d6113b --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-moderation-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerModerationModal } from "./edit-customer-moderation-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-note-modal/edit-customer-note-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-note-modal/edit-customer-note-modal.tsx new file mode 100644 index 000000000..835596667 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-note-modal/edit-customer-note-modal.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "antd"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { cleanOptional, CustomerFormField, CustomerSectionModalFrame, customerNoteSchema, type CustomerNoteValues, useCustomerSectionModal } from "../shared"; + +export function EditCustomerNoteModal() { + const state = useCustomerSectionModal("Merchant note updated"); const initialized = useRef(false); const lastReload = useRef(-1); + const form = useForm({ resolver: zodResolver(customerNoteSchema), defaultValues: { note: "" }, mode: "onChange" }); + const { control, handleSubmit, reset, setError, formState: { errors, isDirty, isValid } } = form; + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; reset({ note: state.customer.note ?? "" }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + const submit = handleSubmit(async (values) => { await state.save({ note: { note: cleanOptional(values.note) } }, { "note.note": "note" }, setError); }); + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}>{state.customer ? } /> : null}; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-note-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-note-modal/index.ts new file mode 100644 index 000000000..00ba7b071 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-note-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerNoteModal } from "./edit-customer-note-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-profile-modal/edit-customer-profile-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-profile-modal/edit-customer-profile-modal.tsx new file mode 100644 index 000000000..adebeb662 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-profile-modal/edit-customer-profile-modal.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input, Select } from "antd"; +import { shopLocales } from "@/defs/localization"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { cleanOptional, CustomerFormField, CustomerSectionModalFrame, customerProfileSchema, type CustomerProfileValues, useCustomerFormStyles, useCustomerSectionModal } from "../shared"; + +export function EditCustomerProfileModal() { + const state = useCustomerSectionModal("Personal profile updated"); + const { styles } = useCustomerFormStyles(); + const initialized = useRef(false); + const lastReload = useRef(-1); + const form = useForm({ resolver: zodResolver(customerProfileSchema), defaultValues: { prefix: "", firstName: "", middleName: "", lastName: "", suffix: "", preferredLocale: "en", dateOfBirth: "", gender: "" }, mode: "onChange" }); + const { control, handleSubmit, reset, setError, formState: { errors, isDirty, isValid } } = form; + + useEffect(() => { + if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; + reset({ prefix: state.customer.prefix ?? "", firstName: state.customer.firstName ?? "", middleName: state.customer.middleName ?? "", lastName: state.customer.lastName ?? "", suffix: state.customer.suffix ?? "", preferredLocale: state.customer.preferredLocale ?? "en", dateOfBirth: state.customer.dateOfBirth ?? "", gender: state.customer.gender ?? "" }); + initialized.current = true; + lastReload.current = state.reloadVersion; + }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const submit = handleSubmit(async (values) => { + await state.save({ profile: { prefix: cleanOptional(values.prefix), firstName: values.firstName.trim(), middleName: cleanOptional(values.middleName), lastName: values.lastName.trim(), suffix: cleanOptional(values.suffix), preferredLocale: values.preferredLocale, dateOfBirth: cleanOptional(values.dateOfBirth), gender: cleanOptional(values.gender) } }, { + "profile.prefix": "prefix", "profile.firstName": "firstName", "profile.middleName": "middleName", "profile.lastName": "lastName", "profile.suffix": "suffix", "profile.preferredLocale": "preferredLocale", "profile.dateOfBirth": "dateOfBirth", "profile.gender": "gender", + }, setError); + }); + + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}> + {state.customer ? <> +
    + } /> + } /> + } /> + } /> + } /> +
    +
    + } /> + } /> +
    + : null} +
    ; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-profile-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-profile-modal/index.ts new file mode 100644 index 000000000..74c6d449b --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-profile-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerProfileModal } from "./edit-customer-profile-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-segments-modal/edit-customer-segments-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-segments-modal/edit-customer-segments-modal.tsx new file mode 100644 index 000000000..6de09c8a4 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-segments-modal/edit-customer-segments-modal.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, Flex, Select, Tag, Typography } from "antd"; +import { CustomerAssignmentSource } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useCustomerEditorContext } from "../../hooks"; +import { enumLabel } from "../../components/customer-details-card/customer-details-utils"; +import { CustomerFormField, CustomerSectionModalFrame, customerSegmentsSchema, type CustomerSegmentsValues, useCustomerSectionModal } from "../shared"; + +export function EditCustomerSegmentsModal() { + const state = useCustomerSectionModal("Manual segments updated"); const context = useCustomerEditorContext(); const initialized = useRef(false); const lastReload = useRef(-1); + const form = useForm({ resolver: zodResolver(customerSegmentsSchema), defaultValues: { segmentIds: [] }, mode: "onChange" }); const { control, handleSubmit, reset, setError, formState: { errors, isDirty, isValid } } = form; + const memberships = state.customer?.segmentMemberships.edges.map((edge) => edge.node) ?? []; const calculated = memberships.filter((item) => item.source !== CustomerAssignmentSource.Manual); const truncated = Boolean(state.customer && state.customer.segmentMemberships.totalCount > state.customer.segmentMemberships.edges.length) || Boolean(context.context?.truncated.segments); + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; reset({ segmentIds: state.customer.segmentMemberships.edges.filter((edge) => edge.node.source === CustomerAssignmentSource.Manual).map((edge) => edge.node.segment.id) }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); const submit = handleSubmit(async (values) => { await state.save({ segments: { segmentIds: values.segmentIds } }, { "segments.segmentIds": "segmentIds" }, setError); }); + return void submit()} onClose={state.pop} queryLoading={state.queryLoading || context.loading} hasCustomer={Boolean(state.customer)} error={state.error ?? context.error?.message ?? null} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}>{state.customer ? <>{truncated ? : null} ({ value: item.id, label: item.name }))} style={{ width: "100%" }} />} /> : null}; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-tags-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-tags-modal/index.ts new file mode 100644 index 000000000..cad39e0d6 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-tags-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerTagsModal } from "./edit-customer-tags-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-tax-exemption-modal/edit-customer-tax-exemption-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-exemption-modal/edit-customer-tax-exemption-modal.tsx new file mode 100644 index 000000000..8ba1e3c5e --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-exemption-modal/edit-customer-tax-exemption-modal.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Flex, Input, Select, Typography } from "antd"; +import { shopCountries } from "@/defs/localization"; +import { CustomerTaxExemptionStatus } from "@/graphql/types"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { useMediaPicker } from "@/shared/components/entity-picker-modal"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { CustomerTaxExemptionItemModalPayload } from "../../modals"; +import { enumLabel } from "../../components/customer-details-card/customer-details-utils"; +import { CustomerFormField, customerTaxExemptionDraftSchema, type CustomerTaxExemptionDraft, useCustomerFormStyles } from "../shared"; + +export function EditCustomerTaxExemptionModal() { + const { payload, pop, forcePop, setDirty } = useModalStackContext(); const value = payload as CustomerTaxExemptionItemModalPayload; const { styles } = useCustomerFormStyles(); + const form = useForm({ resolver: zodResolver(customerTaxExemptionDraftSchema), defaultValues: value.item, mode: "onChange" }); const { control, handleSubmit, setValue, watch, formState: { errors, isDirty, isValid } } = form; const certificate = watch("certificateFile"); + useEffect(() => setDirty(isDirty), [isDirty, setDirty]); + const mediaPicker = useMediaPicker({ selectionMode: "single", maxSelection: 1, accept: "application/pdf,image/*", initialSelection: certificate ? [certificate.id] : [], onConfirm: (files) => { const file = files[0]; if (file) setValue("certificateFile", { id: file.id, originalName: file.originalName, url: file.url }, { shouldDirty: true, shouldValidate: true }); } }); + const apply = handleSubmit((item) => { value.onApply(item); setDirty(false); forcePop(); }); + return void apply() }} />}> +
    + } /> + } /> + } /> + } /> +
    } />
    +
    {certificate?.originalName ?? "No certificate selected"}{certificate ? : null}
    +
    +
    ; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-tax-exemption-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-exemption-modal/index.ts new file mode 100644 index 000000000..7f13140ab --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-exemption-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerTaxExemptionModal } from "./edit-customer-tax-exemption-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-tax-identifier-modal/edit-customer-tax-identifier-modal.tsx b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-identifier-modal/edit-customer-tax-identifier-modal.tsx new file mode 100644 index 000000000..fe515c153 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-identifier-modal/edit-customer-tax-identifier-modal.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Checkbox, Input, Select } from "antd"; +import { shopCountries } from "@/defs/localization"; +import { CustomerTaxIdentifierStatus } from "@/graphql/types"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import type { CustomerTaxIdentifierItemModalPayload } from "../../modals"; +import { enumLabel } from "../../components/customer-details-card/customer-details-utils"; +import { CustomerFormField, customerTaxIdentifierDraftSchema, type CustomerTaxIdentifierDraft, useCustomerFormStyles } from "../shared"; + +export function EditCustomerTaxIdentifierModal() { + const { payload, pop, forcePop, setDirty } = useModalStackContext(); const value = payload as CustomerTaxIdentifierItemModalPayload; const { styles } = useCustomerFormStyles(); + const form = useForm({ resolver: zodResolver(customerTaxIdentifierDraftSchema), defaultValues: value.item, mode: "onChange" }); const { control, handleSubmit, formState: { errors, isDirty, isValid } } = form; + useEffect(() => setDirty(isDirty), [isDirty, setDirty]); const apply = handleSubmit((item) => { value.onApply(item); setDirty(false); forcePop(); }); + return void apply() }} />}> +
    + } /> + } />
    + } /> + } /> + field.onChange(event.target.checked)}>Primary identifier} /> +
    + ; +} diff --git a/admin/src/domains/customers/all-customers/modals/edit-customer-tax-identifier-modal/index.ts b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-identifier-modal/index.ts new file mode 100644 index 000000000..24d00199c --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/edit-customer-tax-identifier-modal/index.ts @@ -0,0 +1 @@ +export { EditCustomerTaxIdentifierModal } from "./edit-customer-tax-identifier-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/manage-customer-addresses-modal/index.ts b/admin/src/domains/customers/all-customers/modals/manage-customer-addresses-modal/index.ts new file mode 100644 index 000000000..ad292dbbc --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/manage-customer-addresses-modal/index.ts @@ -0,0 +1 @@ +export { ManageCustomerAddressesModal } from "./manage-customer-addresses-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/manage-customer-addresses-modal/manage-customer-addresses-modal.tsx b/admin/src/domains/customers/all-customers/modals/manage-customer-addresses-modal/manage-customer-addresses-modal.tsx new file mode 100644 index 000000000..1f48b8346 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/manage-customer-addresses-modal/manage-customer-addresses-modal.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Dropdown, Flex, Select, Tag, Typography } from "antd"; +import { LuEllipsis as MoreOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; +import type { ApiCustomerAddressCreateOperationInput, ApiCustomerAddressPatchInput, ApiCustomerAddressesUpdateInput } from "@/graphql/types"; +import { CustomerAddressValidationStatus } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useCustomerEditAddressModal } from "../../modals"; +import { compactParts, enumLabel } from "../../components/customer-details-card/customer-details-utils"; +import { cleanOptional, customerAddressDrafts, customerAddressManagerSchema, type CustomerAddressDraft, CustomerSectionModalFrame, emptyAddressDraft, sameDraft, useCustomerFormStyles, useCustomerSectionModal } from "../shared"; + +type AddressManagerValues = { items: CustomerAddressDraft[]; deletedIds: string[]; defaultShippingKey: string | null; defaultBillingKey: string | null }; + +const validationColor: Partial> = { + [CustomerAddressValidationStatus.Valid]: "green", + [CustomerAddressValidationStatus.Unvalidated]: "gold", + [CustomerAddressValidationStatus.Invalid]: "red", +}; + +function addressPatch(item: CustomerAddressDraft): ApiCustomerAddressPatchInput { + return { label: cleanOptional(item.label), prefix: cleanOptional(item.prefix), firstName: cleanOptional(item.firstName), middleName: cleanOptional(item.middleName), lastName: cleanOptional(item.lastName), suffix: cleanOptional(item.suffix), companyName: cleanOptional(item.companyName), phoneE164: cleanOptional(item.phoneE164), address1: item.address1.trim(), address2: cleanOptional(item.address2), city: item.city.trim(), regionName: cleanOptional(item.regionName), regionCode: cleanOptional(item.regionCode), postalCode: cleanOptional(item.postalCode), countryCode: item.countryCode }; +} + +function addressCreate(item: CustomerAddressDraft, defaultShippingKey: string | null, defaultBillingKey: string | null): ApiCustomerAddressCreateOperationInput { + return { ...addressPatch(item), address1: item.address1.trim(), city: item.city.trim(), countryCode: item.countryCode, isDefaultShipping: item.key === defaultShippingKey, isDefaultBilling: item.key === defaultBillingKey }; +} + +export function ManageCustomerAddressesModal() { + const state = useCustomerSectionModal("Addresses updated"); const { push: openItem } = useCustomerEditAddressModal(); const { styles } = useCustomerFormStyles(); const initialized = useRef(false); const lastReload = useRef(-1); const original = useRef(new Map()); + const form = useForm({ resolver: zodResolver(customerAddressManagerSchema), defaultValues: { items: [], deletedIds: [], defaultShippingKey: null, defaultBillingKey: null }, mode: "onChange" }); + const { handleSubmit, reset, setError, setValue, watch, formState: { isDirty, isValid } } = form; const items = watch("items"); const deletedIds = watch("deletedIds"); const shippingKey = watch("defaultShippingKey"); const billingKey = watch("defaultBillingKey"); + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; const drafts = customerAddressDrafts(state.customer); original.current = new Map(drafts.filter((item): item is CustomerAddressDraft & { id: string } => Boolean(item.id)).map((item) => [item.id, item])); reset({ items: drafts, deletedIds: [], defaultShippingKey: state.customer.defaultShippingAddress?.id ?? null, defaultBillingKey: state.customer.defaultBillingAddress?.id ?? null }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); + useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + + const applyItem = useCallback((next: CustomerAddressDraft) => { + const current = form.getValues("items"); + const currentShippingKey = form.getValues("defaultShippingKey"); + const currentBillingKey = form.getValues("defaultBillingKey"); + let updated = current.some((item) => item.key === next.key) ? current.map((item) => item.key === next.key ? next : item) : [...current, next]; + if (next.isDefaultShipping) updated = updated.map((item) => ({ ...item, isDefaultShipping: item.key === next.key })); + if (next.isDefaultBilling) updated = updated.map((item) => ({ ...item, isDefaultBilling: item.key === next.key })); + setValue("items", updated, { shouldDirty: true, shouldValidate: true }); + if (next.isDefaultShipping) setValue("defaultShippingKey", next.key, { shouldDirty: true }); + else if (currentShippingKey === next.key) setValue("defaultShippingKey", null, { shouldDirty: true }); + if (next.isDefaultBilling) setValue("defaultBillingKey", next.key, { shouldDirty: true }); + else if (currentBillingKey === next.key) setValue("defaultBillingKey", null, { shouldDirty: true }); + }, [form, setValue]); + + const setDefault = (kind: "shipping" | "billing", key: string | null) => { + const flag = kind === "shipping" ? "isDefaultShipping" : "isDefaultBilling"; + setValue("items", form.getValues("items").map((item) => ({ ...item, [flag]: item.key === key })), { shouldDirty: true, shouldValidate: true }); + setValue(kind === "shipping" ? "defaultShippingKey" : "defaultBillingKey", key, { shouldDirty: true }); + }; + + const remove = (item: CustomerAddressDraft) => { + setValue("items", form.getValues("items").filter((candidate) => candidate.key !== item.key), { shouldDirty: true, shouldValidate: true }); + if (item.id && !deletedIds.includes(item.id)) setValue("deletedIds", [...deletedIds, item.id], { shouldDirty: true }); + if (shippingKey === item.key) setDefault("shipping", null); + if (billingKey === item.key) setDefault("billing", null); + }; + + const submit = handleSubmit(async (values) => { + const operations: ApiCustomerAddressesUpdateInput = { + create: values.items.filter((item) => !item.id).map((item) => addressCreate(item, values.defaultShippingKey, values.defaultBillingKey)), + update: values.items.filter((item): item is CustomerAddressDraft & { id: string } => Boolean(item.id)).filter((item) => !sameDraft(addressPatch(item), addressPatch(original.current.get(item.id)!))).map((item) => ({ addressId: item.id, operations: addressPatch(item) })), + deleteIds: values.deletedIds, + }; + const shipping = values.items.find((item) => item.key === values.defaultShippingKey); const billing = values.items.find((item) => item.key === values.defaultBillingKey); + if (!shipping || shipping.id) operations.defaultShippingAddressId = shipping?.id ?? null; + if (!billing || billing.id) operations.defaultBillingAddressId = billing?.id ?? null; + await state.save({ addresses: operations }, { "addresses.create": "items", "addresses.update": "items", "addresses.deleteIds": "deletedIds", "addresses.defaultShippingAddressId": "defaultShippingKey", "addresses.defaultBillingAddressId": "defaultBillingKey" }, setError); + }); + + const options = items.map((item, index) => ({ value: item.key, label: item.label || compactParts([item.address1, item.city]) || `Address ${index + 1}` })); const truncated = state.customer ? state.customer.addresses.totalCount > state.customer.addresses.edges.length : false; + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}> + {state.customer ? <>{state.customer.addresses.totalCount} total} /> + {items.map((item, index) => + {item.label || `Address ${index + 1}`}{item.key === shippingKey ? Shipping : null}{item.key === billingKey ? Billing : null}{enumLabel(item.validationStatus)}{compactParts([item.address1, item.city, item.regionCode])} + openItem({ item, title: "Edit address", onApply: applyItem }) }, + { key: "shipping", label: "Set as shipping default", "data-testid": `customer-address-${index}-shipping-menu-item`, onClick: () => setDefault("shipping", item.key) }, + { key: "billing", label: "Set as billing default", "data-testid": `customer-address-${index}-billing-menu-item`, onClick: () => setDefault("billing", item.key) }, + { type: "divider" as const }, { key: "remove", label: "Remove", danger: true, "data-testid": `customer-address-${index}-remove-menu-item`, onClick: () => remove(item) }, + ] }}> + {truncated ? Showing {state.customer.addresses.edges.length} of {state.customer.addresses.totalCount}. Only explicit removals are submitted; defaults outside the first page remain available. : null} + +
    Shipping setDefault("billing", value ?? null)} style={{ width: "100%" }} />
    : null} +
    ; +} diff --git a/admin/src/domains/customers/all-customers/modals/manage-customer-tax-exemptions-modal/index.ts b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-exemptions-modal/index.ts new file mode 100644 index 000000000..74467e24f --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-exemptions-modal/index.ts @@ -0,0 +1 @@ +export { ManageCustomerTaxExemptionsModal } from "./manage-customer-tax-exemptions-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/manage-customer-tax-exemptions-modal/manage-customer-tax-exemptions-modal.tsx b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-exemptions-modal/manage-customer-tax-exemptions-modal.tsx new file mode 100644 index 000000000..6f4a951d8 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-exemptions-modal/manage-customer-tax-exemptions-modal.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Dropdown, Flex, Tag, Typography } from "antd"; +import { LuEllipsis as MoreOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; +import type { ApiCustomerTaxExemptionsUpdateInput } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useCustomerEditTaxExemptionModal } from "../../modals"; +import { compactParts, customerTaxExemptionStatusColor, enumLabel, formatCustomerDate } from "../../components/customer-details-card/customer-details-utils"; +import { cleanOptional, CustomerSectionModalFrame, customerTaxExemptionsManagerSchema, emptyExemptionDraft, exemptionToDraft, sameDraft, type CustomerTaxExemptionDraft, useCustomerFormStyles, useCustomerSectionModal } from "../shared"; + +type ManagerValues = { items: CustomerTaxExemptionDraft[]; deletedIds: string[] }; +const input = (item: CustomerTaxExemptionDraft) => ({ code: item.code.trim(), countryCode: cleanOptional(item.countryCode), regionCode: cleanOptional(item.regionCode), reason: cleanOptional(item.reason), status: item.status, certificateFileId: item.certificateFile?.id ?? null, validFrom: cleanOptional(item.validFrom), validTo: cleanOptional(item.validTo) }); + +export function ManageCustomerTaxExemptionsModal() { + const state = useCustomerSectionModal("Tax exemptions updated"); const { push: openItem } = useCustomerEditTaxExemptionModal(); const { styles } = useCustomerFormStyles(); const initialized = useRef(false); const lastReload = useRef(-1); const original = useRef(new Map()); + const form = useForm({ resolver: zodResolver(customerTaxExemptionsManagerSchema), defaultValues: { items: [], deletedIds: [] }, mode: "onChange" }); const { handleSubmit, reset, setError, setValue, watch, formState: { isDirty, isValid } } = form; const items = watch("items"); const deletedIds = watch("deletedIds"); + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; const drafts = state.customer.taxExemptions.edges.map((edge) => exemptionToDraft(edge.node)); original.current = new Map(drafts.map((item) => [item.id!, item])); reset({ items: drafts, deletedIds: [] }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + const applyItem = useCallback((next: CustomerTaxExemptionDraft) => { const current = form.getValues("items"); setValue("items", current.some((item) => item.key === next.key) ? current.map((item) => item.key === next.key ? next : item) : [...current, next], { shouldDirty: true, shouldValidate: true }); }, [form, setValue]); + const remove = (item: CustomerTaxExemptionDraft) => { setValue("items", form.getValues("items").filter((candidate) => candidate.key !== item.key), { shouldDirty: true, shouldValidate: true }); if (item.id && !deletedIds.includes(item.id)) setValue("deletedIds", [...deletedIds, item.id], { shouldDirty: true }); }; + const submit = handleSubmit(async (values) => { const operations: ApiCustomerTaxExemptionsUpdateInput = { create: values.items.filter((item) => !item.id).map(input), update: values.items.filter((item): item is CustomerTaxExemptionDraft & { id: string } => Boolean(item.id)).filter((item) => !sameDraft(input(item), input(original.current.get(item.id)!))).map((item) => ({ taxExemptionId: item.id, operations: input(item) })), deleteIds: values.deletedIds }; await state.save({ taxExemptions: operations }, { "taxExemptions.create": "items", "taxExemptions.update": "items", "taxExemptions.deleteIds": "deletedIds" }, setError); }); + const truncated = state.customer ? state.customer.taxExemptions.totalCount > state.customer.taxExemptions.edges.length : false; + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}>{state.customer ? {items.map((item, index) => {compactParts([item.code, compactParts([item.countryCode, item.regionCode], " / "), item.validTo ? `through ${formatCustomerDate(item.validTo)}` : null])}{enumLabel(item.status)}{item.certificateFile ? {item.certificateFile.originalName} : null} openItem({ item, title: "Edit tax exemption", onApply: applyItem }) }, { key: "remove", label: "Remove", danger: true, "data-testid": `customer-tax-exemption-${index}-remove-menu-item`, onClick: () => remove(item) }] }}>{truncated ? Showing {state.customer.taxExemptions.edges.length} of {state.customer.taxExemptions.totalCount}. Only explicit removals are submitted. : null} : null}; +} diff --git a/admin/src/domains/customers/all-customers/modals/manage-customer-tax-identifiers-modal/index.ts b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-identifiers-modal/index.ts new file mode 100644 index 000000000..210cf8abc --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-identifiers-modal/index.ts @@ -0,0 +1 @@ +export { ManageCustomerTaxIdentifiersModal } from "./manage-customer-tax-identifiers-modal"; diff --git a/admin/src/domains/customers/all-customers/modals/manage-customer-tax-identifiers-modal/manage-customer-tax-identifiers-modal.tsx b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-identifiers-modal/manage-customer-tax-identifiers-modal.tsx new file mode 100644 index 000000000..e1177376d --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/manage-customer-tax-identifiers-modal/manage-customer-tax-identifiers-modal.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Dropdown, Flex, Tag, Typography } from "antd"; +import { LuEllipsis as MoreOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; +import type { ApiCustomerTaxIdentifiersUpdateInput } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useCustomerEditTaxIdentifierModal } from "../../modals"; +import { compactParts, customerTaxIdentifierStatusColor, enumLabel } from "../../components/customer-details-card/customer-details-utils"; +import { cleanOptional, CustomerSectionModalFrame, customerTaxIdentifiersManagerSchema, emptyIdentifierDraft, identifierToDraft, sameDraft, type CustomerTaxIdentifierDraft, useCustomerFormStyles, useCustomerSectionModal } from "../shared"; + +type ManagerValues = { items: CustomerTaxIdentifierDraft[]; deletedIds: string[] }; +const input = (item: CustomerTaxIdentifierDraft) => ({ identifierType: item.identifierType.trim(), countryCode: cleanOptional(item.countryCode), value: item.value.trim(), status: item.status, isPrimary: item.isPrimary, validFrom: cleanOptional(item.validFrom), validTo: cleanOptional(item.validTo) }); + +export function ManageCustomerTaxIdentifiersModal() { + const state = useCustomerSectionModal("Tax identifiers updated"); const { push: openItem } = useCustomerEditTaxIdentifierModal(); const { styles } = useCustomerFormStyles(); const initialized = useRef(false); const lastReload = useRef(-1); const original = useRef(new Map()); + const form = useForm({ resolver: zodResolver(customerTaxIdentifiersManagerSchema), defaultValues: { items: [], deletedIds: [] }, mode: "onChange" }); const { handleSubmit, reset, setError, setValue, watch, formState: { isDirty, isValid } } = form; const items = watch("items"); const deletedIds = watch("deletedIds"); + useEffect(() => { if (!state.customer || (initialized.current && lastReload.current === state.reloadVersion)) return; const drafts = state.customer.taxIdentifiers.edges.map((edge) => identifierToDraft(edge.node)); original.current = new Map(drafts.map((item) => [item.id!, item])); reset({ items: drafts, deletedIds: [] }); initialized.current = true; lastReload.current = state.reloadVersion; }, [reset, state.customer, state.reloadVersion]); useEffect(() => state.setDirty(isDirty), [isDirty, state.setDirty]); + const applyItem = useCallback((next: CustomerTaxIdentifierDraft) => { let updated = form.getValues("items"); updated = updated.some((item) => item.key === next.key) ? updated.map((item) => item.key === next.key ? next : item) : [...updated, next]; if (next.isPrimary) updated = updated.map((item) => ({ ...item, isPrimary: item.key === next.key })); setValue("items", updated, { shouldDirty: true, shouldValidate: true }); }, [form, setValue]); + const remove = (item: CustomerTaxIdentifierDraft) => { setValue("items", form.getValues("items").filter((candidate) => candidate.key !== item.key), { shouldDirty: true, shouldValidate: true }); if (item.id && !deletedIds.includes(item.id)) setValue("deletedIds", [...deletedIds, item.id], { shouldDirty: true }); }; + const submit = handleSubmit(async (values) => { const operations: ApiCustomerTaxIdentifiersUpdateInput = { create: values.items.filter((item) => !item.id).map(input), update: values.items.filter((item): item is CustomerTaxIdentifierDraft & { id: string } => Boolean(item.id)).filter((item) => !sameDraft(input(item), input(original.current.get(item.id)!))).map((item) => ({ taxIdentifierId: item.id, operations: input(item) })), deleteIds: values.deletedIds }; await state.save({ taxIdentifiers: operations }, { "taxIdentifiers.create": "items", "taxIdentifiers.update": "items", "taxIdentifiers.deleteIds": "deletedIds" }, setError); }); + const truncated = state.customer ? state.customer.taxIdentifiers.totalCount > state.customer.taxIdentifiers.edges.length : false; + return void submit()} onClose={state.pop} queryLoading={state.queryLoading} hasCustomer={Boolean(state.customer)} error={state.error} conflict={state.conflict} onReload={() => void state.reloadLatest(isDirty)}>{state.customer ? {items.map((item, index) => {compactParts([item.identifierType, item.countryCode, item.value])}{enumLabel(item.status)}{item.isPrimary ? Primary : null} openItem({ item, title: "Edit tax identifier", onApply: applyItem }) }, { key: "remove", label: "Remove", danger: true, "data-testid": `customer-tax-identifier-${index}-remove-menu-item`, onClick: () => remove(item) }] }}>{truncated ? Showing {state.customer.taxIdentifiers.edges.length} of {state.customer.taxIdentifiers.totalCount}. Only explicit removals are submitted. : null} : null}; +} diff --git a/admin/src/domains/customers/all-customers/modals/shared/customer-drafts.ts b/admin/src/domains/customers/all-customers/modals/shared/customer-drafts.ts new file mode 100644 index 000000000..ac1d40997 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/shared/customer-drafts.ts @@ -0,0 +1,173 @@ +import type { ApiCustomer, ApiCustomerAddress, ApiCustomerTaxExemption, ApiCustomerTaxIdentifier, ApiFile } from "@/graphql/types"; +import { CustomerAddressValidationStatus, CustomerTaxExemptionStatus, CustomerTaxIdentifierStatus } from "@/graphql/types"; + +export type CustomerAddressDraft = { + key: string; + id?: string; + label: string; + prefix: string; + firstName: string; + middleName: string; + lastName: string; + suffix: string; + companyName: string; + phoneE164: string; + address1: string; + address2: string; + city: string; + regionName: string; + regionCode: string; + postalCode: string; + countryCode: string; + validationStatus: CustomerAddressValidationStatus; + isDefaultShipping: boolean; + isDefaultBilling: boolean; +}; + +export type CustomerTaxIdentifierDraft = { + key: string; + id?: string; + identifierType: string; + countryCode: string; + value: string; + status: CustomerTaxIdentifierStatus; + isPrimary: boolean; + validFrom: string; + validTo: string; +}; + +export type CustomerTaxExemptionDraft = { + key: string; + id?: string; + code: string; + countryCode: string; + regionCode: string; + reason: string; + status: CustomerTaxExemptionStatus; + certificateFile: Pick | null; + validFrom: string; + validTo: string; +}; + +export const draftKey = () => `new-${crypto.randomUUID()}`; + +export function addressToDraft(address: ApiCustomerAddress): CustomerAddressDraft { + return { + key: address.id, + id: address.id, + label: address.label ?? "", + prefix: address.prefix ?? "", + firstName: address.firstName ?? "", + middleName: address.middleName ?? "", + lastName: address.lastName ?? "", + suffix: address.suffix ?? "", + companyName: address.companyName ?? "", + phoneE164: address.phoneE164 ?? "", + address1: address.address1, + address2: address.address2 ?? "", + city: address.city, + regionName: address.regionName ?? "", + regionCode: address.regionCode ?? "", + postalCode: address.postalCode ?? "", + countryCode: address.countryCode, + validationStatus: address.validationStatus, + isDefaultShipping: address.isDefaultShipping, + isDefaultBilling: address.isDefaultBilling, + }; +} + +export function customerAddressDrafts(customer: ApiCustomer) { + const loaded = customer.addresses.edges.map((edge) => edge.node); + const byId = new Map(loaded.map((item) => [item.id, item])); + if (customer.defaultShippingAddress) byId.set(customer.defaultShippingAddress.id, customer.defaultShippingAddress); + if (customer.defaultBillingAddress) byId.set(customer.defaultBillingAddress.id, customer.defaultBillingAddress); + return [...byId.values()].map(addressToDraft); +} + +export function emptyAddressDraft(): CustomerAddressDraft { + return { + key: draftKey(), + label: "", + prefix: "", + firstName: "", + middleName: "", + lastName: "", + suffix: "", + companyName: "", + phoneE164: "", + address1: "", + address2: "", + city: "", + regionName: "", + regionCode: "", + postalCode: "", + countryCode: "", + validationStatus: CustomerAddressValidationStatus.Unvalidated, + isDefaultShipping: false, + isDefaultBilling: false, + }; +} + +export function identifierToDraft(item: ApiCustomerTaxIdentifier): CustomerTaxIdentifierDraft { + return { + key: item.id, + id: item.id, + identifierType: item.identifierType, + countryCode: item.countryCode ?? "", + value: item.value, + status: item.status, + isPrimary: item.isPrimary, + validFrom: item.validFrom ?? "", + validTo: item.validTo ?? "", + }; +} + +export function emptyIdentifierDraft(): CustomerTaxIdentifierDraft { + return { + key: draftKey(), + identifierType: "", + countryCode: "", + value: "", + status: CustomerTaxIdentifierStatus.Unverified, + isPrimary: false, + validFrom: "", + validTo: "", + }; +} + +export function exemptionToDraft(item: ApiCustomerTaxExemption): CustomerTaxExemptionDraft { + return { + key: item.id, + id: item.id, + code: item.code, + countryCode: item.countryCode ?? "", + regionCode: item.regionCode ?? "", + reason: item.reason ?? "", + status: item.status, + certificateFile: item.certificateFile ? { id: item.certificateFile.id, originalName: item.certificateFile.originalName, url: item.certificateFile.url } : null, + validFrom: item.validFrom ?? "", + validTo: item.validTo ?? "", + }; +} + +export function emptyExemptionDraft(): CustomerTaxExemptionDraft { + return { + key: draftKey(), + code: "", + countryCode: "", + regionCode: "", + reason: "", + status: CustomerTaxExemptionStatus.Active, + certificateFile: null, + validFrom: "", + validTo: "", + }; +} + +export function cleanOptional(value: string) { + return value.trim() || null; +} + +export function sameDraft(left: unknown, right: unknown) { + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/admin/src/domains/customers/all-customers/modals/shared/customer-form.styles.ts b/admin/src/domains/customers/all-customers/modals/shared/customer-form.styles.ts new file mode 100644 index 000000000..3cdd60c8b --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/shared/customer-form.styles.ts @@ -0,0 +1,18 @@ +import { createStyles } from "antd-style"; + +export const useCustomerFormStyles = createStyles(({ token }) => ({ + container: { display: "flex", flexDirection: "column", gap: token.marginSM }, + grid: { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: token.margin, "@media (max-width: 620px)": { gridTemplateColumns: "minmax(0, 1fr)" } }, + threeColumns: { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: token.margin, "@media (max-width: 720px)": { gridTemplateColumns: "minmax(0, 1fr)" } }, + full: { gridColumn: "1 / -1" }, + field: { minWidth: 0 }, + label: { display: "block", marginBottom: 6, fontWeight: 500 }, + help: { display: "block", marginTop: 4, color: token.colorTextSecondary, fontSize: token.fontSizeSM }, + error: { display: "block", marginTop: 4, color: token.colorError, fontSize: token.fontSizeSM }, + collectionRow: { paddingBlock: token.paddingSM, borderBottom: `1px solid ${token.colorBorderSecondary}`, "&:last-child": { borderBottom: 0 } }, + rowSummary: { minWidth: 0 }, + statusOptions: { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", "@media (max-width: 480px)": { gridTemplateColumns: "minmax(0, 1fr)" } }, + statusOption: { minHeight: 52, borderRadius: 0, "&:first-child": { borderStartStartRadius: token.borderRadiusLG, borderEndStartRadius: token.borderRadiusLG }, "&:last-child": { borderStartEndRadius: token.borderRadiusLG, borderEndEndRadius: token.borderRadiusLG }, "@media (max-width: 480px)": { "&:first-child": { borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0` }, "&:last-child": { borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px` } } }, + consequence: { padding: token.padding, borderRadius: token.borderRadiusLG, background: token.colorFillTertiary }, + truncationAlert: { marginBottom: token.marginSM }, +})); diff --git a/admin/src/domains/customers/all-customers/modals/shared/customer-section-modal.tsx b/admin/src/domains/customers/all-customers/modals/shared/customer-section-modal.tsx new file mode 100644 index 000000000..5164c8aea --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/shared/customer-section-modal.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; +import type { FieldValues, Path, UseFormSetError } from "react-hook-form"; +import { Alert, App, Button, Skeleton, Typography } from "antd"; +import type { ApiCustomerUpdateInput, ApiGenericUserError } from "@/graphql/types"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { useCustomer, useUpdateCustomer } from "../../hooks"; +import type { CustomerSectionModalPayload } from "../../modals"; +import { useCustomerFormStyles } from "./customer-form.styles"; + +export function CustomerFormField({ label, error, help, children }: { label: string; error?: string; help?: string; children: ReactNode }) { + const { styles } = useCustomerFormStyles(); + return ( +
    + {label} + {children} + {error ? {error} : null} + {!error && help ? {help} : null} +
    + ); +} + +export function CustomerSectionModalFrame({ name, title, children, loading, disabled, onSubmit, onClose, queryLoading, hasCustomer, error, conflict, onReload }: { + name: string; title: string; children: ReactNode; loading: boolean; disabled: boolean; onSubmit: () => void; onClose: () => void; queryLoading: boolean; hasCustomer: boolean; error: string | null; conflict: boolean; onReload: () => void; +}) { + return ( + }> + {conflict ? Reload latest data} /> : null} + {error ? : null} + {queryLoading && !hasCustomer ? : children} + {!queryLoading && !hasCustomer ? : null} + + ); +} + +export function useCustomerSectionModal(successMessage: string) { + const { message, modal } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as CustomerSectionModalPayload; + const query = useCustomer(value.entityId); + const mutation = useUpdateCustomer(); + const [error, setError] = useState(null); + const [conflict, setConflict] = useState(false); + const [reloadVersion, setReloadVersion] = useState(0); + + const save = useCallback(async (operations: ApiCustomerUpdateInput, fieldMap: Record>, setFieldError: UseFormSetError, onUserErrors?: (errors: ApiGenericUserError[]) => void) => { + if (!query.customer) return false; + setError(null); + setConflict(false); + const result = await mutation.updateCustomer(query.customer.id, query.customer.revision, operations); + if (!result.customer || result.userErrors.length) { + onUserErrors?.(result.userErrors); + const isConflict = result.userErrors.some((item) => item.code === "REVISION_CONFLICT"); + setConflict(isConflict); + const unmapped: string[] = []; + for (const item of result.userErrors) { + if (item.code === "REVISION_CONFLICT") continue; + const path = item.field?.join(".") ?? ""; + const match = Object.entries(fieldMap).find(([apiPath]) => path === apiPath || path.endsWith(`.${apiPath}`)); + if (match) setFieldError(match[1], { message: item.message }); + else unmapped.push(item.message); + } + if (unmapped.length) setError(unmapped.join(" ")); + return false; + } + await value.onSaved?.(); + setDirty(false); + message.success(successMessage); + forcePop(); + return true; + }, [forcePop, message, mutation, query.customer, setDirty, successMessage, value]); + + const reloadLatest = useCallback(async (isDirty: boolean) => { + if (isDirty) { + const confirmed = await modal.confirm({ + title: "Reload latest customer data?", + content: "Your unsaved changes will be replaced.", + okText: "Reload", + }); + if (!confirmed) return; + } + setError(null); + await query.refetch(); + setConflict(false); + setReloadVersion((current) => current + 1); + }, [modal, query]); + + return { + payload: value, + customer: query.customer, + queryLoading: query.loading, + mutationLoading: mutation.loading, + error: error ?? query.error?.message ?? mutation.error?.message ?? null, + conflict, + reloadVersion, + reloadLatest, + save, + pop, + setDirty, + setGlobalError: setError, + }; +} diff --git a/admin/src/domains/customers/all-customers/modals/shared/customer-section-schemas.ts b/admin/src/domains/customers/all-customers/modals/shared/customer-section-schemas.ts new file mode 100644 index 000000000..31cb79c99 --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/shared/customer-section-schemas.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import { CustomerAddressValidationStatus, CustomerAdminLifecycleStatus, CustomerConsentAdminState, CustomerConsentChannel, CustomerConsentOptInLevel, CustomerTaxExemptionStatus, CustomerTaxIdentifierStatus } from "@/graphql/types"; + +const optionalText = z.string().max(2000); +const optionalPhone = z.string().refine((value) => !value || /^\+[1-9]\d{7,14}$/.test(value), "Use E.164 format, for example +12025550147"); + +export const customerProfileSchema = z.object({ + prefix: z.string(), + firstName: z.string().trim().min(1, "First name is required"), + middleName: z.string(), + lastName: z.string().trim().min(1, "Last name is required"), + suffix: z.string(), + preferredLocale: z.string().min(1, "Preferred locale is required"), + dateOfBirth: z.string(), + gender: z.string(), +}); +export type CustomerProfileValues = z.infer; + +export const customerContactSchema = z.object({ + email: z.string().refine((value) => !value || z.email().safeParse(value).success, "Enter a valid email"), + phoneE164: optionalPhone, +}); +export type CustomerContactValues = z.infer; + +export const customerCompanySchema = z.object({ companyName: z.string(), jobTitle: z.string() }); +export type CustomerCompanyValues = z.infer; + +export const customerStatusSchema = z.object({ + status: z.enum(CustomerAdminLifecycleStatus), + blockedReason: z.string(), +}).superRefine((value, context) => { + if (value.status === CustomerAdminLifecycleStatus.Blocked && !value.blockedReason.trim()) { + context.addIssue({ code: "custom", path: ["blockedReason"], message: "A block reason is required" }); + } +}); +export type CustomerStatusValues = z.infer; + +export const customerNoteSchema = z.object({ note: optionalText }); +export type CustomerNoteValues = z.infer; +export const customerModerationSchema = z.object({ moderationNote: optionalText }); +export type CustomerModerationValues = z.infer; + +export const customerGroupsSchema = z.object({ groupIds: z.array(z.string()), primaryGroupId: z.string().nullable() }).refine((value) => !value.primaryGroupId || value.groupIds.includes(value.primaryGroupId), { path: ["primaryGroupId"], message: "Primary group must be selected" }); +export type CustomerGroupsValues = z.infer; +export const customerTagsSchema = z.object({ tagIds: z.array(z.string()) }); +export type CustomerTagsValues = z.infer; +export const customerSegmentsSchema = z.object({ segmentIds: z.array(z.string()) }); +export type CustomerSegmentsValues = z.infer; + +export const customerAddressDraftSchema = z.object({ + key: z.string(), id: z.string().optional(), label: z.string(), prefix: z.string(), firstName: z.string(), middleName: z.string(), lastName: z.string(), suffix: z.string(), companyName: z.string(), phoneE164: optionalPhone, + address1: z.string().trim().min(1, "Address line 1 is required"), address2: z.string(), city: z.string().trim().min(1, "City is required"), regionName: z.string(), regionCode: z.string(), postalCode: z.string(), countryCode: z.string().min(1, "Country is required"), validationStatus: z.enum(CustomerAddressValidationStatus), isDefaultShipping: z.boolean(), isDefaultBilling: z.boolean(), +}); +export const customerAddressManagerSchema = z.object({ items: z.array(customerAddressDraftSchema), deletedIds: z.array(z.string()), defaultShippingKey: z.string().nullable(), defaultBillingKey: z.string().nullable() }); + +export const customerConsentRowSchema = z.object({ channel: z.enum(CustomerConsentChannel), enabled: z.boolean(), locked: z.boolean(), existingState: z.string().nullable(), state: z.enum(CustomerConsentAdminState).nullable(), optInLevel: z.enum(CustomerConsentOptInLevel), contactPoint: z.string() }).superRefine((value, context) => { + if (value.enabled && !value.state) context.addIssue({ code: "custom", path: ["state"], message: "Choose a consent state" }); + if (value.enabled && !value.contactPoint.trim()) context.addIssue({ code: "custom", path: ["contactPoint"], message: "Contact point is required" }); +}); +export const customerConsentsSchema = z.object({ channels: z.array(customerConsentRowSchema) }); +export type CustomerConsentsValues = z.infer; + +export const customerTaxIdentifierDraftSchema = z.object({ key: z.string(), id: z.string().optional(), identifierType: z.string().trim().min(1, "Type is required"), countryCode: z.string(), value: z.string().trim().min(1, "Value is required"), status: z.enum(CustomerTaxIdentifierStatus), isPrimary: z.boolean(), validFrom: z.string(), validTo: z.string() }); +export const customerTaxIdentifiersManagerSchema = z.object({ items: z.array(customerTaxIdentifierDraftSchema), deletedIds: z.array(z.string()) }); +export const customerTaxExemptionDraftSchema = z.object({ key: z.string(), id: z.string().optional(), code: z.string().trim().min(1, "Code is required"), countryCode: z.string(), regionCode: z.string(), reason: z.string(), status: z.enum(CustomerTaxExemptionStatus), certificateFile: z.object({ id: z.string(), originalName: z.string().nullable().optional(), url: z.string() }).nullable(), validFrom: z.string(), validTo: z.string() }); +export const customerTaxExemptionsManagerSchema = z.object({ items: z.array(customerTaxExemptionDraftSchema), deletedIds: z.array(z.string()) }); diff --git a/admin/src/domains/customers/all-customers/modals/shared/index.ts b/admin/src/domains/customers/all-customers/modals/shared/index.ts new file mode 100644 index 000000000..b900b0c3b --- /dev/null +++ b/admin/src/domains/customers/all-customers/modals/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./customer-drafts"; +export * from "./customer-form.styles"; +export * from "./customer-section-modal"; +export * from "./customer-section-schemas"; diff --git a/admin/src/domains/customers/all-customers/page/filter-schema.ts b/admin/src/domains/customers/all-customers/page/filter-schema.ts new file mode 100644 index 000000000..4a7188738 --- /dev/null +++ b/admin/src/domains/customers/all-customers/page/filter-schema.ts @@ -0,0 +1,110 @@ +import { allowedCountries, shopCountries } from "@/defs/localization"; +import { + FilterType, + dateOperators, + enumOperators, + numberOperators, + priceOperators, +} from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { + CustomerLifecycleStatus, + CustomerConsentState, +} from "@/graphql/types"; + +const countryOptions = allowedCountries.map((countryCode) => ({ + value: countryCode, + label: shopCountries.find((country) => country.value === countryCode)?.name ?? countryCode, +})); + +/** Required operational filters for support and sales workflows. */ +export const filterSchema: IFilterSchema[] = [ + { + key: "status", + label: "Status", + description: "Filter by customer account status", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "lifecycleStatus", + options: [ + { label: "Active", value: CustomerLifecycleStatus.Active }, + { label: "Disabled", value: CustomerLifecycleStatus.Disabled }, + { label: "Blocked", value: CustomerLifecycleStatus.Blocked }, + ], + }, + { + key: "marketing", + label: "Email marketing", + description: "Filter by explicit email consent state", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "emailMarketingState", + options: [ + { label: "Subscribed", value: CustomerConsentState.Subscribed }, + { label: "Not subscribed", value: CustomerConsentState.NotSubscribed }, + { label: "Pending confirmation", value: CustomerConsentState.Pending }, + { label: "Unsubscribed", value: CustomerConsentState.Unsubscribed }, + ], + }, + { + key: "country", + label: "Country", + description: "Filter by default-address country", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "defaultShippingCountryCode", + options: countryOptions, + }, + { + key: "ordersCount", + label: "Orders", + description: "Filter by completed order count", + type: FilterType.Integer, + operators: numberOperators, + payloadKey: "ordersCount", + }, + { + key: "totalSpent", + label: "Total spent", + description: "Filter by lifetime spend in the project currency", + type: FilterType.Price, + operators: priceOperators, + payloadKey: "totalSpentMinor", + }, + { + key: "lastOrderAt", + label: "Last order", + description: "Filter by last-order date", + type: FilterType.DateRange, + operators: dateOperators, + payloadKey: "lastOrderAt", + }, + { + key: "createdAt", + label: "Customer since", + description: "Filter by profile creation date", + type: FilterType.DateRange, + operators: dateOperators, + payloadKey: "createdAt", + }, +]; + +export function createCustomerFilterSchema( + segments: Array<{ id: string; name: string }>, +): IFilterSchema[] { + if (segments.length === 0) return filterSchema; + + return [ + filterSchema[0]!, + { + key: "segment", + label: "Segment", + description: "Filter by assigned customer segment", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "segmentId", + options: segments.map((segment) => ({ label: segment.name, value: segment.id })), + }, + ...filterSchema.slice(1), + ]; +} diff --git a/admin/src/domains/customers/all-customers/page/page-config.ts b/admin/src/domains/customers/all-customers/page/page-config.ts new file mode 100644 index 000000000..e5666945b --- /dev/null +++ b/admin/src/domains/customers/all-customers/page/page-config.ts @@ -0,0 +1,65 @@ +import { + createGraphqlDateTimeRangeFilterTransformer, +} from "@/layouts/filters"; +import { + createMinorUnitPriceTransformer, + type FilterTransformer, + type SortFieldMapping, + type UsePageConfigReturn, +} from "@/hooks"; +import { + CustomerOrderField, + SortDirection, + type ApiCustomerOrderByInput, + type ApiCustomerWhereInput, +} from "@/graphql/types"; +import type { CustomersQueryVariables } from "../graphql/operation-types"; + +export const customerSortFieldMapping: SortFieldMapping = { + displayName: CustomerOrderField.DisplayName, + email: CustomerOrderField.Email, + lifecycleStatus: CustomerOrderField.LifecycleStatus, + ordersCount: CustomerOrderField.OrdersCount, + totalSpentMinor: CustomerOrderField.TotalSpentMinor, + lastOrderAt: CustomerOrderField.LastOrderAt, + createdAt: CustomerOrderField.CreatedAt, + updatedAt: CustomerOrderField.UpdatedAt, +}; + +export const buildCustomerSearchCondition = ( + search: string, +): Partial => ({ + _or: [ + { displayName: { _containsi: search } }, + { email: { _containsi: search } }, + { phoneE164: { _containsi: search } }, + ], +}); + +export const customerFilterTransformers: Record< + string, + FilterTransformer +> = { + totalSpentMinor: createMinorUnitPriceTransformer("totalSpentMinor"), + lastOrderAt: createGraphqlDateTimeRangeFilterTransformer("lastOrderAt"), + createdAt: createGraphqlDateTimeRangeFilterTransformer("createdAt"), +}; + +export function buildCustomersQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): CustomersQueryVariables { + return { + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: pageConfig.orderBy?.map((order) => ({ + field: order.field, + direction: order.direction === SortDirection.Asc ? SortDirection.Asc : SortDirection.Desc, + })) as ApiCustomerOrderByInput[] | undefined, + }; +} diff --git a/admin/src/domains/customers/all-customers/page/page.tsx b/admin/src/domains/customers/all-customers/page/page.tsx new file mode 100644 index 000000000..376e4ed8c --- /dev/null +++ b/admin/src/domains/customers/all-customers/page/page.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, Avatar, Button, Flex, Tag, Typography } from "antd"; +import { LuCircleCheckBig as CheckCircleFilled, LuClock as ClockCircleOutlined, LuMapPin as EnvironmentOutlined, LuMail as MailOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { + AllCommunityModule, + GridStateModule, + ModuleRegistry, + type ColDef, +} from "ag-grid-community"; +import { useDefaultCurrency } from "@/domains/workspace"; +import { + CustomerConsentChannel, + CustomerConsentState, + CustomerLifecycleStatus, + CustomerOrderField, + CustomerSegmentStatus, + type ApiCustomer, + type ApiCustomerWhereInput, +} from "@/graphql/types"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useCustomers } from "../hooks"; +import { useCustomerSegments } from "../../segments/hooks"; +import { useCustomerCreateModal, useCustomerModal } from "../modals"; +import { createCustomerFilterSchema } from "./filter-schema"; +import { + buildCustomerSearchCondition, + buildCustomersQueryVariables, + customerFilterTransformers, + customerSortFieldMapping, +} from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); + +const statusConfig: Record = { + [CustomerLifecycleStatus.Active]: { color: "green", label: "Active" }, + [CustomerLifecycleStatus.Disabled]: { color: "default", label: "Disabled" }, + [CustomerLifecycleStatus.Blocked]: { color: "red", label: "Blocked" }, + [CustomerLifecycleStatus.Merged]: { color: "purple", label: "Merged" }, + [CustomerLifecycleStatus.Redacted]: { color: "default", label: "Redacted" }, +}; + +const customerDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +function formatMoney(amountMinor: number, currency: string | null): string { + if (!currency) return "—"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + currencyDisplay: "narrowSymbol", + }).format(amountMinor / 100); +} + +function CustomerCell({ data }: CustomCellRendererProps) { + if (!data) return null; + const initials = `${data.firstName?.[0] ?? ""}${data.lastName?.[0] ?? ""}`.toUpperCase(); + + return ( + + {initials} + + {data.displayName} + {data.email ?? "No email"} + + + ); +} + +function StatusCell({ value }: CustomCellRendererProps) { + const config = statusConfig[value ?? CustomerLifecycleStatus.Active]; + return {config.label}; +} + +function MarketingCell({ value }: CustomCellRendererProps) { + if (value === CustomerConsentState.Subscribed) { + return Subscribed; + } + if (value === CustomerConsentState.Pending) { + return Pending; + } + return Not subscribed; +} + +function LocationCell({ data }: CustomCellRendererProps) { + if (!data?.defaultShippingAddress) return No address; + return ( + + + + {data.defaultShippingAddress.city}, {data.defaultShippingAddress.countryCode} + + + ); +} + +function LastOrderCell({ value }: CustomCellRendererProps) { + return value + ? {customerDateFormatter.format(new Date(value))} + : No orders; +} + +export default function AllCustomersPage() { + const agGridTheme = useAgGridTheme(); + const defaultCurrency = useDefaultCurrency(); + const segmentQuery = useCustomerSegments({ + first: 250, + where: { status: { _eq: CustomerSegmentStatus.Active } }, + }); + const filterSchema = useMemo( + () => createCustomerFilterSchema( + segmentQuery.connection?.edges.map((edge) => edge.node) ?? [], + ), + [segmentQuery.connection], + ); + const gridRef = useRef>(null); + const pageConfig = usePageConfig({ + gridRef, + storageKey: "customers-grid-state", + filterSchema, + sortFieldMapping: customerSortFieldMapping, + defaultSort: [{ colId: "lastOrderAt", sort: "desc" }], + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], + buildSearchCondition: buildCustomerSearchCondition, + filterTransformers: customerFilterTransformers, + }); + const variables = useMemo( + () => ({ ...buildCustomersQueryVariables(pageConfig), currencyCode: defaultCurrency }), + [ + pageConfig.first, + pageConfig.after, + pageConfig.last, + pageConfig.before, + pageConfig.where, + pageConfig.orderBy, + defaultCurrency, + ], + ); + const { customers, totalCount, pageInfo, loading, error, refetch } = useCustomers(variables); + const { push: openCustomerModal } = useCustomerModal(); + const { push: openCustomerCreateModal } = useCustomerCreateModal(); + + const handleCreate = useCallback(() => { + openCustomerCreateModal({ onCreated: refetch }); + }, [openCustomerCreateModal, refetch]); + + const handleEdit = useCallback((customer: ApiCustomer) => { + openCustomerModal({ entityId: customer.id, onSaved: refetch }); + }, [openCustomerModal, refetch]); + + const handleNextPage = useCallback(() => { + if (pageInfo?.endCursor) pageConfig.goToNextPage(pageInfo.endCursor); + }, [pageConfig, pageInfo?.endCursor]); + + const handlePrevPage = useCallback(() => { + if (pageInfo?.startCursor) pageConfig.goToPrevPage(pageInfo.startCursor); + }, [pageConfig, pageInfo?.startCursor]); + + const columnDefs = useMemo[]>(() => [ + { + headerName: "Customer", + colId: "displayName", + cellRenderer: CustomerCell, + minWidth: 280, + flex: 2, + }, + { + headerName: "Status", + field: "lifecycleStatus", + cellRenderer: StatusCell, + width: 120, + }, + { + headerName: "Marketing", + valueGetter: ({ data }) => data?.consents.find((consent) => consent.channel === CustomerConsentChannel.Email)?.state ?? CustomerConsentState.NotSubscribed, + cellRenderer: MarketingCell, + minWidth: 165, + sortable: false, + }, + { + headerName: "Location", + colId: "location", + cellRenderer: LocationCell, + minWidth: 165, + sortable: false, + }, + { + headerName: "Orders", + colId: "ordersCount", + valueGetter: ({ data }) => data?.statistics?.ordersCount ?? 0, + width: 105, + }, + { + headerName: "Total spent", + colId: "totalSpentMinor", + valueGetter: ({ data }) => data?.monetaryStatistics.edges[0]?.node.totalSpentMinor ?? 0, + valueFormatter: ({ value }) => formatMoney(Number(value ?? 0), defaultCurrency), + minWidth: 135, + }, + { + headerName: "Last order", + colId: "lastOrderAt", + valueGetter: ({ data }) => data?.statistics?.lastOrderAt ?? null, + cellRenderer: LastOrderCell, + minWidth: 145, + }, + ], [defaultCurrency]); + + const defaultColDef = useMemo>(() => ({ + resizable: true, + sortable: true, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }), []); + + return ( + } onClick={handleCreate}>Create customer} + > + } + /> + +
    + {error ? : null} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={customers} + loading={loading} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={64} + suppressCellFocus + suppressMovableColumns + rowStyle={{ cursor: "pointer" }} + onRowClicked={({ data }) => { if (data) handleEdit(data); }} + onSortChanged={pageConfig.onSortChanged} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + /> +
    + + +
    +
    + ); +} diff --git a/admin/src/domains/customers/all-customers/picker/customer-picker-config.tsx b/admin/src/domains/customers/all-customers/picker/customer-picker-config.tsx new file mode 100644 index 000000000..daa5b235e --- /dev/null +++ b/admin/src/domains/customers/all-customers/picker/customer-picker-config.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { createElement, useMemo } from "react"; +import { Tag } from "antd"; +import { LuUser as UserOutlined } from "react-icons/lu"; +import type { ColDef } from "ag-grid-community"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { + EntityCellRenderer, +} from "@/shared/components/entity-picker-modal/cell-renderers"; +import { + registerEntityPickerConfig, +} from "@/shared/components/entity-picker-modal/configs"; +import type { + IEntityPickerConfig, + IEntityPickerDataResult, + IPickableEntity, +} from "@/shared/components/entity-picker-modal/types"; +import { useCustomers } from "../hooks"; +import { filterSchema } from "../page/filter-schema"; +import { + buildCustomerSearchCondition, + customerFilterTransformers, + customerSortFieldMapping, +} from "../page/page-config"; +import { + type ApiCustomer, + type ApiCustomerOrderByInput, + type ApiCustomerWhereInput, + CustomerOrderField, + CustomerLifecycleStatus, +} from "@/graphql/types"; + +interface CustomerPickerEntity extends IPickableEntity { + email: string; + ordersCount: number; + customerStatus: CustomerLifecycleStatus; +} + +function transformCustomer(customer: ApiCustomer): CustomerPickerEntity { + return { + id: customer.id, + title: customer.displayName, + email: customer.email ?? "", + ordersCount: customer.statistics?.ordersCount ?? 0, + customerStatus: customer.lifecycleStatus, + }; +} + +const statusCopy: Record = { + [CustomerLifecycleStatus.Active]: { label: "Active", color: "green" }, + [CustomerLifecycleStatus.Disabled]: { label: "Disabled", color: "default" }, + [CustomerLifecycleStatus.Blocked]: { label: "Blocked", color: "red" }, + [CustomerLifecycleStatus.Merged]: { label: "Merged", color: "purple" }, + [CustomerLifecycleStatus.Redacted]: { label: "Redacted", color: "default" }, +}; + +function CustomerStatusCell({ value }: CustomCellRendererProps) { + const status = statusCopy[value ?? CustomerLifecycleStatus.Active]; + return {status.label}; +} + +function useCustomersPickerData(options: { + pageSize: number; + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: object | null; + orderBy?: object[] | null; + excludeIds: string[]; +}): IEntityPickerDataResult { + const { + pageSize, + first, + after, + last, + before, + where, + orderBy, + excludeIds, + } = options; + const customerWhere = useMemo(() => { + const conditions: ApiCustomerWhereInput[] = []; + if (where) conditions.push(where as ApiCustomerWhereInput); + if (excludeIds.length > 0) conditions.push({ id: { _notIn: excludeIds } }); + if (conditions.length === 0) return null; + if (conditions.length === 1) return conditions[0]!; + return { _and: conditions }; + }, [excludeIds, where]); + const { customers, totalCount, pageInfo, loading, error } = useCustomers({ + first, + after, + last, + before, + where: customerWhere, + orderBy: orderBy as ApiCustomerOrderByInput[] | null, + }); + const data = useMemo(() => customers.map(transformCustomer), [customers]); + + return { + data, + isLoading: loading, + error, + pagination: { + total: totalCount, + pageSize, + hasNext: pageInfo?.hasNextPage ?? false, + hasPrev: pageInfo?.hasPreviousPage ?? false, + startCursor: pageInfo?.startCursor ?? null, + endCursor: pageInfo?.endCursor ?? null, + }, + }; +} + +const customerPickerColumns: ColDef[] = [ + { + headerName: "Customer", + colId: "displayName", + field: "title", + cellRenderer: EntityCellRenderer, + cellRendererParams: { fallbackIcon: createElement(UserOutlined) }, + flex: 1, + minWidth: 250, + }, + { + headerName: "Email", + field: "email", + minWidth: 230, + }, + { + headerName: "Orders", + colId: "ordersCount", + field: "ordersCount", + width: 110, + }, + { + headerName: "Status", + colId: "status", + field: "customerStatus", + cellRenderer: CustomerStatusCell, + width: 125, + }, +]; + +export const customerPickerConfig: IEntityPickerConfig< + CustomerPickerEntity, + ApiCustomerWhereInput, + CustomerOrderField +> = { + entityType: "customer", + entityName: "Customer", + entityNamePlural: "Customers", + filterSchema, + columns: customerPickerColumns, + pageConfig: { + storageKey: "customer-picker-grid-state", + sortFieldMapping: customerSortFieldMapping, + buildSearchCondition: buildCustomerSearchCondition, + filterTransformers: customerFilterTransformers, + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50], + }, + useData: useCustomersPickerData, + getRowId: (customer) => customer.id, +}; + +registerEntityPickerConfig(customerPickerConfig); diff --git a/admin/src/domains/customers/groups/graphql/operations.ts b/admin/src/domains/customers/groups/graphql/operations.ts new file mode 100644 index 000000000..69296f943 --- /dev/null +++ b/admin/src/domains/customers/groups/graphql/operations.ts @@ -0,0 +1,80 @@ +import { gql } from "@apollo/client"; + +export const CUSTOMER_GROUP_FIELDS = gql` + fragment CustomerGroupFields on CustomerGroup { + id + code + name + description + isDefault + isActive + revision + customersCount + createdAt + updatedAt + } +`; + +export const CUSTOMER_GROUPS_QUERY = gql` + query CustomerGroups($first: Int, $after: String, $where: CustomerGroupWhereInput, $orderBy: [CustomerGroupOrderByInput!]) { + customersQuery { + customerGroups(first: $first, after: $after, where: $where, orderBy: $orderBy) { + edges { cursor node { ...CustomerGroupFields } } + pageInfo { hasNextPage hasPreviousPage startCursor endCursor } + totalCount + } + } + } + ${CUSTOMER_GROUP_FIELDS} +`; + +export const CUSTOMER_GROUP_QUERY = gql` + query CustomerGroup($id: ID!) { + customersQuery { + customerGroup(id: $id) { + ...CustomerGroupFields + customerMemberships(first: 250, orderBy: [{ field: assignedAt, direction: asc }]) { + edges { + node { + id + isPrimary + source + assignedAt + expiresAt + isActive + customer { id displayName email } + } + } + totalCount + } + } + } + } + ${CUSTOMER_GROUP_FIELDS} +`; + +export const CUSTOMER_GROUP_CREATE_MUTATION = gql` + mutation CustomerGroupCreate($input: CustomerGroupCreateInput!) { + customersMutation { customerGroupCreate(input: $input) { group { ...CustomerGroupFields } userErrors { code field message } } } + } + ${CUSTOMER_GROUP_FIELDS} +`; + +export const CUSTOMER_GROUP_UPDATE_MUTATION = gql` + mutation CustomerGroupUpdate($groupId: ID!, $expectedRevision: Int!, $operations: CustomerGroupUpdateInput!) { + customersMutation { + customerGroupUpdate(groupId: $groupId, expectedRevision: $expectedRevision, operations: $operations) { + group { ...CustomerGroupFields } + operationResults { type applied errors { code field message } } + userErrors { code field message } + } + } + } + ${CUSTOMER_GROUP_FIELDS} +`; + +export const CUSTOMER_GROUP_DELETE_MUTATION = gql` + mutation CustomerGroupDelete($input: CustomerGroupDeleteInput!) { + customersMutation { customerGroupDelete(input: $input) { deletedGroupId userErrors { code field message } } } + } +`; diff --git a/admin/src/domains/customers/groups/hooks/index.ts b/admin/src/domains/customers/groups/hooks/index.ts new file mode 100644 index 000000000..c93b60f84 --- /dev/null +++ b/admin/src/domains/customers/groups/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-customer-groups"; diff --git a/admin/src/domains/customers/groups/hooks/use-customer-groups.ts b/admin/src/domains/customers/groups/hooks/use-customer-groups.ts new file mode 100644 index 000000000..3e97df00e --- /dev/null +++ b/admin/src/domains/customers/groups/hooks/use-customer-groups.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useMutation, useQuery } from "@apollo/client/react"; +import type { + ApiCustomerGroup, + ApiCustomerGroupConnection, + ApiCustomerGroupCreateInput, + ApiCustomerGroupCreatePayload, + ApiCustomerGroupDeleteInput, + ApiCustomerGroupDeletePayload, + ApiCustomerGroupOrderByInput, + ApiCustomerGroupUpdateInput, + ApiCustomerGroupUpdatePayload, + ApiCustomerGroupWhereInput, + ApiCustomersMutation, + ApiCustomersQuery, + ApiGenericUserError, +} from "@/graphql/types"; +import { + CUSTOMER_GROUP_CREATE_MUTATION, + CUSTOMER_GROUP_DELETE_MUTATION, + CUSTOMER_GROUP_QUERY, + CUSTOMER_GROUP_UPDATE_MUTATION, + CUSTOMER_GROUPS_QUERY, +} from "../graphql/operations"; + +export interface CustomerGroupsVariables { + first?: number; after?: string | null; where?: ApiCustomerGroupWhereInput | null; orderBy?: ApiCustomerGroupOrderByInput[] | null; +} + +export function useCustomerGroups(variables: CustomerGroupsVariables) { + const query = useQuery<{ customersQuery: Pick & { customerGroups: ApiCustomerGroupConnection } }, CustomerGroupsVariables>(CUSTOMER_GROUPS_QUERY, { variables, fetchPolicy: "cache-and-network" }); + const connection = (query.data ?? query.previousData)?.customersQuery.customerGroups ?? null; + return { groups: connection?.edges.map((edge) => edge.node) ?? [], connection, totalCount: connection?.totalCount ?? 0, pageInfo: connection?.pageInfo ?? null, loading: query.loading, error: query.error ?? null, refetch: query.refetch }; +} + +export function useCustomerGroup(id?: string) { + const query = useQuery<{ customersQuery: Pick & { customerGroup: ApiCustomerGroup | null } }, { id: string }>(CUSTOMER_GROUP_QUERY, { variables: { id: id ?? "" }, skip: !id, fetchPolicy: "cache-and-network" }); + return { group: (query.data ?? query.previousData)?.customersQuery.customerGroup ?? null, loading: query.loading, error: query.error ?? null, refetch: query.refetch }; +} + +const unexpected = (cause: unknown) => [{ code: "UNEXPECTED_ERROR", message: cause instanceof Error ? cause.message : "Unexpected error" }] as ApiGenericUserError[]; + +export function useCustomerGroupMutations() { + const [createMutation, createState] = useMutation<{ customersMutation: Pick & { customerGroupCreate: ApiCustomerGroupCreatePayload } }, { input: ApiCustomerGroupCreateInput }>(CUSTOMER_GROUP_CREATE_MUTATION); + const [updateMutation, updateState] = useMutation<{ customersMutation: Pick & { customerGroupUpdate: ApiCustomerGroupUpdatePayload } }, { groupId: string; expectedRevision: number; operations: ApiCustomerGroupUpdateInput }>(CUSTOMER_GROUP_UPDATE_MUTATION); + const [deleteMutation, deleteState] = useMutation<{ customersMutation: Pick & { customerGroupDelete: ApiCustomerGroupDeletePayload } }, { input: ApiCustomerGroupDeleteInput }>(CUSTOMER_GROUP_DELETE_MUTATION); + return { + createGroup: async (input: ApiCustomerGroupCreateInput) => { try { const result = await createMutation({ variables: { input }, refetchQueries: [CUSTOMER_GROUPS_QUERY] }); return result.data?.customersMutation.customerGroupCreate ?? { group: null, userErrors: [] }; } catch (cause) { return { group: null, userErrors: unexpected(cause) }; } }, + updateGroup: async (groupId: string, expectedRevision: number, operations: ApiCustomerGroupUpdateInput) => { try { const result = await updateMutation({ variables: { groupId, expectedRevision, operations }, refetchQueries: [CUSTOMER_GROUPS_QUERY] }); const payload = result.data?.customersMutation.customerGroupUpdate; return { group: payload?.group ?? null, userErrors: [...(payload?.userErrors ?? []), ...(payload?.operationResults.flatMap((item) => item.errors) ?? [])] }; } catch (cause) { return { group: null, userErrors: unexpected(cause) }; } }, + deleteGroup: async (input: ApiCustomerGroupDeleteInput) => { try { const result = await deleteMutation({ variables: { input }, refetchQueries: [CUSTOMER_GROUPS_QUERY] }); return result.data?.customersMutation.customerGroupDelete ?? { deletedGroupId: null, userErrors: [] }; } catch (cause) { return { deletedGroupId: null, userErrors: unexpected(cause) }; } }, + loading: createState.loading || updateState.loading || deleteState.loading, + error: createState.error ?? updateState.error ?? deleteState.error ?? null, + }; +} diff --git a/admin/src/domains/customers/groups/modals.ts b/admin/src/domains/customers/groups/modals.ts new file mode 100644 index 000000000..e0847c910 --- /dev/null +++ b/admin/src/domains/customers/groups/modals.ts @@ -0,0 +1,7 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; + +export const CUSTOMER_GROUP_MODAL_TYPE = "customer-group"; +export interface CustomerGroupModalPayload extends IModalStackPayload { mode: "create" | "edit"; entityId?: string; onSaved?: () => Promise | unknown; } +declare module "@/layouts/modals" { interface ModalStackPayloads { [CUSTOMER_GROUP_MODAL_TYPE]: CustomerGroupModalPayload; } } +export const useCustomerGroupModal = createModalStackHook(CUSTOMER_GROUP_MODAL_TYPE); diff --git a/admin/src/domains/customers/groups/modals/group-modal/group-modal.tsx b/admin/src/domains/customers/groups/modals/group-modal/group-modal.tsx new file mode 100644 index 000000000..8fcd0c440 --- /dev/null +++ b/admin/src/domains/customers/groups/modals/group-modal/group-modal.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Alert, App, Button, Checkbox, Descriptions, Empty, Flex, Input, Select, Skeleton, Tag, Typography } from "antd"; +import { LuTrash2 as DeleteOutlined, LuUsers as TeamOutlined } from "react-icons/lu"; +import { createStyles } from "antd-style"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useEntityPicker } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import "@/domains/customers/all-customers/picker/customer-picker-config"; +import { useCustomerGroup, useCustomerGroupMutations } from "../../hooks"; +import type { CustomerGroupModalPayload } from "../../modals"; + +const useStyles = createStyles(({ token }) => ({ fields: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: token.padding }, full: { gridColumn: "1 / -1" }, label: { display: "block", marginBottom: 6, fontWeight: 500 } })); + +export function CustomerGroupModal() { + const { styles } = useStyles(); + const { message, modal } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const value = payload as CustomerGroupModalPayload; + const isEdit = value.mode === "edit"; + const query = useCustomerGroup(isEdit ? value.entityId : undefined); + const mutations = useCustomerGroupMutations(); + const [definition, setDefinition] = useState({ code: "", name: "", description: "" }); + const [state, setState] = useState({ isDefault: false, isActive: true }); + const [customerIds, setCustomerIds] = useState([]); + const [selectedCustomers, setSelectedCustomers] = useState([]); + const [primaryCustomerId, setPrimaryCustomerId] = useState(null); + const [dirty, setLocalDirty] = useState(false); + const [error, setError] = useState(null); + const group = query.group; + + useEffect(() => { + if (!group) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate the edit draft when the requested group arrives. + setDefinition({ code: group.code, name: group.name, description: group.description ?? "" }); + setState({ isDefault: group.isDefault, isActive: group.isActive }); + const memberships = group.customerMemberships.edges.map((edge) => edge.node); + setCustomerIds(memberships.map((item) => item.customer.id)); + setSelectedCustomers(memberships.map((item) => ({ id: item.customer.id, title: item.customer.displayName }))); + setPrimaryCustomerId(memberships.find((item) => item.isPrimary)?.customer.id ?? null); + setLocalDirty(false); + }, [group]); + useEffect(() => setDirty(dirty), [dirty, setDirty]); + const existing = useMemo(() => group?.customerMemberships.edges.map((edge) => edge.node) ?? [], [group]); + const { openPicker } = useEntityPicker({ entityType: "customer", selectionMode: "multi", initialSelection: customerIds, allowEmptySelection: true, onConfirm: (items, ids) => { + const labels = new Map([...selectedCustomers, ...items].map((item) => [item.id, item])); + setCustomerIds(ids); + setSelectedCustomers(ids.map((id) => labels.get(id) ?? { id, title: id })); + if (primaryCustomerId && !ids.includes(primaryCustomerId)) setPrimaryCustomerId(null); + setLocalDirty(true); + } }); + const customerOptions = selectedCustomers.map((item) => ({ value: item.id, label: item.title })); + + const save = async () => { + setError(null); + if (!definition.code.trim() || !definition.name.trim()) return setError("Code and name are required."); + const result = !group ? await mutations.createGroup({ code: definition.code.trim(), name: definition.name.trim(), description: definition.description.trim() || null, ...state }) : await mutations.updateGroup(group.id, group.revision, { + definition: { code: definition.code.trim(), name: definition.name.trim(), description: definition.description.trim() || null }, state, + memberships: { + create: customerIds.filter((id) => !existing.some((item) => item.customer.id === id)).map((customerId) => ({ customerId, isPrimary: customerId === primaryCustomerId })), + update: existing.filter((item) => customerIds.includes(item.customer.id)).map((item) => ({ membershipId: item.id, isPrimary: item.customer.id === primaryCustomerId, expiresAt: item.expiresAt })), + deleteIds: existing.filter((item) => !customerIds.includes(item.customer.id)).map((item) => item.id), + }, + }); + if (!result.group || result.userErrors.length) return setError(result.userErrors.map((item) => item.message).join(" ") || "Unable to save group"); + await value.onSaved?.(); setDirty(false); message.success(group ? "Group updated" : "Group created"); forcePop(); + }; + + const remove = async () => { if (!group) return; const confirmed = await modal.confirm({ title: "Delete customer group?", content: group.name, okText: "Delete", okButtonProps: { danger: true } }); if (!confirmed) return; const result = await mutations.deleteGroup({ id: group.id }); if (!result.deletedGroupId) return setError(result.userErrors.map((item) => item.message).join(" ") || "Unable to delete group"); await value.onSaved?.(); forcePop(); }; + + if (isEdit && query.loading && !group) return ; + return {group.isActive ? "Active" : "Inactive"} : null} submitButtonProps={{ children: group ? "Save" : "Create", loading: mutations.loading, disabled: !dirty && !!group, onClick: save }} />}> + {query.error || mutations.error ? : null}{error ? : null} +
    { setDefinition({ ...definition, code: e.target.value }); setLocalDirty(true); }} />
    { setDefinition({ ...definition, name: e.target.value }); setLocalDirty(true); }} />
    { setDefinition({ ...definition, description: e.target.value }); setLocalDirty(true); }} />
    { setState({ ...state, isActive: e.target.checked }); setLocalDirty(true); }}>Active { setState({ ...state, isDefault: e.target.checked }); setLocalDirty(true); }}>Default group
    + {group ? } actions={} />{customerIds.length ? {selectedCustomers.map((item) => {item.title})}
    Primary customer membership + )} + /> + {errors.name ?
    {errors.name.message}
    : null} +
    +
    + + ( + ({ value, label: value.toLowerCase() }))} + onChange={(value) => { setSegmentType(value); setAdvancedDirty(true); }} + style={{ width: "100%" }} + /> +
    +
    + + { setName(event.target.value); setLocalDirty(true); }} />{tag ? } actions={} />{selectedCustomers.length ? {selectedCustomers.map((item) => {item.title})} : } : null}{tag ? : null}; +} diff --git a/admin/src/domains/customers/tags/page/page.tsx b/admin/src/domains/customers/tags/page/page.tsx new file mode 100644 index 000000000..f9a34ea91 --- /dev/null +++ b/admin/src/domains/customers/tags/page/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import { useCallback, useMemo, useState } from "react"; +import { Alert, Button, Input, Typography } from "antd"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react"; +import { AllCommunityModule, ModuleRegistry, type ColDef } from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; import { useAgGridTheme } from "@/hooks"; import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { CustomerTagOrderField, SortDirection, type ApiCustomerTag } from "@/graphql/types"; +import { useCustomerTags } from "../hooks"; import { useCustomerTagModal } from "../modals"; +ModuleRegistry.registerModules([AllCommunityModule]); +function TagCell({ data }: CustomCellRendererProps) { return data ?
    {data.name}
    {data.normalizedName}
    : null; } +export default function CustomerTagsPage() { const theme = useAgGridTheme(); const [search, setSearch] = useState(""); const [history, setHistory] = useState>([null]); const [page, setPage] = useState(0); const query = useCustomerTags({ first: 20, after: history[page], where: search ? { name: { _containsi: search } } : null, orderBy: [{ field: CustomerTagOrderField.Name, direction: SortDirection.Asc }] }); const { push } = useCustomerTagModal(); const create = useCallback(() => push({ mode: "create", onSaved: query.refetch }), [push, query.refetch]); const edit = useCallback((tag: ApiCustomerTag) => push({ mode: "edit", entityId: tag.id, onSaved: query.refetch }), [push, query.refetch]); const columns = useMemo[]>(() => [{ headerName: "Tag", cellRenderer: TagCell, minWidth: 280, flex: 2 }, { headerName: "Customers", field: "customersCount", width: 140 }, { headerName: "Updated", field: "updatedAt", valueFormatter: ({ value }) => value ? new Date(value).toLocaleDateString() : "", width: 160 }], []); return } onClick={create}>Create tag}> { setSearch(value.trim()); setPage(0); setHistory([null]); }} style={{ width: 320 }} />} />
    {query.error ? : null}
    theme={theme} rowData={query.tags} loading={query.loading} columnDefs={columns} getRowId={({ data }) => data.id} rowHeight={58} onRowClicked={({ data }) => data && edit(data)} rowStyle={{ cursor: "pointer" }} />
    0} onNext={() => { const cursor = query.pageInfo?.endCursor; if (!cursor) return; setHistory((items) => [...items.slice(0, page + 1), cursor]); setPage((value) => value + 1); }} onPrev={() => setPage((value) => Math.max(0, value - 1))} onPageSizeChange={() => undefined} />
    ; } diff --git a/admin/src/domains/dashboard/page/page.tsx b/admin/src/domains/dashboard/page/page.tsx new file mode 100644 index 000000000..ce10ec19c --- /dev/null +++ b/admin/src/domains/dashboard/page/page.tsx @@ -0,0 +1,5 @@ +import { EmptySectionPage } from "@/shared/components/empty-section-page"; + +export default function DashboardPage() { + return ; +} diff --git a/admin/src/domains/dashboard/register.tsx b/admin/src/domains/dashboard/register.tsx new file mode 100644 index 000000000..f70f274d7 --- /dev/null +++ b/admin/src/domains/dashboard/register.tsx @@ -0,0 +1,21 @@ +import { registerModule } from "@/registry"; +import dynamic from "next/dynamic"; +import { LuLayoutDashboard } from "react-icons/lu"; + +registerModule({ + key: "dashboard", + domain: "store", + sidebar: { + label: "Dashboard", + icon: , + order: 1, + }, + items: [ + { + key: "dashboard", + path: "/:orgName/:storeName/dashboard", + disabled: true, + component: dynamic(() => import("@/domains/dashboard/page/page")), + }, + ], +}); diff --git a/admin/src/domains/discovery/facets/components/facet-cell-styles.ts b/admin/src/domains/discovery/facets/components/facet-cell-styles.ts new file mode 100644 index 000000000..6e8759e43 --- /dev/null +++ b/admin/src/domains/discovery/facets/components/facet-cell-styles.ts @@ -0,0 +1,135 @@ +import { createStyles } from "antd-style"; + +export const useFacetCellStyles = createStyles(({ token }) => ({ + nameCell: { + display: "flex", + alignItems: "center", + gap: 6, + width: "100%", + minWidth: 0, + }, + nameText: { + minWidth: 0, + display: "flex", + flexDirection: "column", + lineHeight: 1.25, + }, + sourceIcon: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + color: token.colorTextSecondary, + fontSize: 14, + }, + expandIcon: { + cursor: "pointer", + fontSize: 10, + color: token.colorTextSecondary, + width: 16, + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "0 0 auto", + "&:hover": { + color: token.colorText, + }, + }, + expandIconPlaceholder: { + width: 16, + flex: "0 0 auto", + }, + indent: { + display: "inline-block", + flex: "0 0 auto", + }, + valueIcon: { + color: token.colorSuccess, + fontSize: 14, + flex: "0 0 auto", + }, + secondary: { + fontSize: 12, + color: token.colorTextSecondary, + }, + actionsCell: { + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + actionButton: { + width: 28, + height: 28, + minWidth: 28, + padding: 0, + flex: "0 0 auto", + }, + linkedCell: { + display: "flex", + alignItems: "center", + gap: 6, + minWidth: 0, + }, + valuesCell: { + display: "flex", + alignItems: "center", + gap: 4, + minWidth: 0, + overflow: "hidden", + }, + valueTag: { + maxWidth: 120, + marginInlineEnd: 0, + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + minHeight: 22, + paddingInline: 7, + borderRadius: token.borderRadiusSM, + background: token.colorFillQuaternary, + color: token.colorTextSecondary, + fontWeight: 500, + overflow: "hidden", + transition: "background 0.2s, color 0.2s", + "&:hover": { + background: token.colorFillSecondary, + color: token.colorText, + }, + "& .ant-typography": { + maxWidth: "100%", + color: "inherit", + fontSize: 12, + lineHeight: "20px", + }, + }, + disabledValueText: { + color: token.colorTextDisabled, + }, + controlsCell: { + display: "flex", + alignItems: "center", + gap: 8, + minWidth: 0, + }, + swatchCell: { + display: "flex", + alignItems: "center", + gap: 8, + }, + swatchDot: { + width: 18, + height: 18, + borderRadius: "50%", + border: `1px solid ${token.colorBorder}`, + flex: "0 0 auto", + }, + swatchImage: { + width: 22, + height: 22, + objectFit: "cover", + borderRadius: 4, + border: `1px solid ${token.colorBorder}`, + }, +})); diff --git a/admin/src/domains/discovery/facets/components/facet-name-cell.tsx b/admin/src/domains/discovery/facets/components/facet-name-cell.tsx new file mode 100644 index 000000000..31b1bfafe --- /dev/null +++ b/admin/src/domains/discovery/facets/components/facet-name-cell.tsx @@ -0,0 +1,30 @@ +import { Typography } from "antd"; +import type { ICellRendererParams } from "ag-grid-community"; +import { getFacetTypeIcon, type FacetGridRow } from "../mappers"; +import { useFacetCellStyles } from "./facet-cell-styles"; + +export function FacetNameCell(params: ICellRendererParams) { + const { styles } = useFacetCellStyles(); + const row = params.data; + if (!row) { + return null; + } + + return ( +
    + {row.facetType ? ( + {getFacetTypeIcon(row.facetType)} + ) : null} + + + {row.name} + + +
    + ); +} diff --git a/admin/src/domains/discovery/facets/components/facet-scopes-settings-paper.tsx b/admin/src/domains/discovery/facets/components/facet-scopes-settings-paper.tsx new file mode 100644 index 000000000..257b5aba4 --- /dev/null +++ b/admin/src/domains/discovery/facets/components/facet-scopes-settings-paper.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Alert, Flex, Skeleton, Tag, Typography } from "antd"; +import { LuChevronRight as RightOutlined } from "react-icons/lu"; +import { createStyles } from "antd-style"; +import { FacetScopeType, FacetType } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useFacets } from "../hooks"; +import { + FACET_UI_MAPPINGS, + getFacetScopeLabel, + getFacetTypeIcon, +} from "../mappers"; +import { useFacetScopePickerModal } from "../modals"; + +const FACET_TYPE_ORDER = [ + FacetType.Price, + FacetType.Tag, + FacetType.Feature, + FacetType.Option, + FacetType.InStock, +] as const; + +const SCOPE_ORDER = [FacetScopeType.Search, FacetScopeType.Category] as const; + +const SINGLETON_FACET_TYPES = new Set([ + FacetType.Price, + FacetType.Tag, + FacetType.InStock, +]); + +const useStyles = createStyles(({ token }) => ({ + description: { + display: "block", + marginTop: -token.marginXS, + marginBottom: token.margin, + }, + rows: { + display: "flex", + flexDirection: "column", + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + overflow: "hidden", + }, + row: { + display: "grid", + gridTemplateColumns: "minmax(150px, 0.55fr) minmax(0, 1fr) auto", + gap: token.padding, + alignItems: "center", + width: "100%", + minHeight: 66, + padding: `${token.paddingSM}px ${token.padding}px`, + border: 0, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + background: token.colorBgContainer, + color: "inherit", + textAlign: "left", + cursor: "pointer", + transition: "background 0.2s", + "&:last-child": { + borderBottom: 0, + }, + "&:hover": { + background: token.colorFillQuaternary, + }, + "&:focus-visible": { + outline: `2px solid ${token.colorPrimary}`, + outlineOffset: -2, + }, + "@media (max-width: 760px)": { + gridTemplateColumns: "minmax(0, 1fr) auto", + }, + }, + summary: { + minWidth: 0, + "@media (max-width: 760px)": { + gridColumn: "1 / -1", + gridRow: 2, + }, + }, + metric: { + marginInlineEnd: 0, + minHeight: 26, + display: "inline-flex", + alignItems: "center", + gap: 5, + "& svg": { + display: "block", + }, + }, + chevron: { + color: token.colorTextTertiary, + }, +})); + +export function FacetScopesSettingsPaper() { + const { styles } = useStyles(); + const { facets, loading, error } = useFacets(); + const { push: openFacetScopePicker } = useFacetScopePickerModal(); + + return ( + + + + Choose which facets are available in each storefront listing context. + + + {error ? ( + + ) : loading && facets.length === 0 ? ( + + ) : ( +
    + {SCOPE_ORDER.map((scope) => ( + + ))} +
    + )} +
    + ); +} diff --git a/admin/src/domains/discovery/facets/components/facet-select-cell.tsx b/admin/src/domains/discovery/facets/components/facet-select-cell.tsx new file mode 100644 index 000000000..b03f054b6 --- /dev/null +++ b/admin/src/domains/discovery/facets/components/facet-select-cell.tsx @@ -0,0 +1,84 @@ +import { Select } from "antd"; +import type { ICellRendererParams } from "ag-grid-community"; +import { + FacetSelectionMode, + FacetUiType, +} from "@/graphql/types"; +import type { FacetGridRow } from "../mappers"; +import { + getAllowedFacetUiTypes, + getDefaultFacetSelectionMode, +} from "../mappers"; +import { useFacetCellStyles } from "./facet-cell-styles"; + +export interface FacetSelectCellParams + extends ICellRendererParams { + onUiTypeChange: (row: FacetGridRow, value: FacetUiType) => void; + onSelectionModeChange: ( + row: FacetGridRow, + value: FacetSelectionMode, + ) => void; +} + +export function FacetDisplayTypeCell(params: FacetSelectCellParams) { + const { styles } = useFacetCellStyles(); + const row = params.data; + if (!row || !row.facetType) { + return null; + } + + const allowedUiTypes = getAllowedFacetUiTypes(row.facetType); + + return ( +
    + ({ + value, + label: value, + }))} + style={{ minWidth: 96 }} + onClick={(event) => event.stopPropagation()} + onChange={(value) => params.onSelectionModeChange(row, value)} + /> +
    + ); +} diff --git a/admin/src/domains/discovery/facets/components/facet-tree-actions-cell.tsx b/admin/src/domains/discovery/facets/components/facet-tree-actions-cell.tsx new file mode 100644 index 000000000..56ee9ea18 --- /dev/null +++ b/admin/src/domains/discovery/facets/components/facet-tree-actions-cell.tsx @@ -0,0 +1,85 @@ +import { LuCopy as CopyOutlined, LuTrash2 as DeleteOutlined, LuPencil as EditOutlined, LuEllipsis as MoreOutlined } from "react-icons/lu"; +import { Button, Dropdown } from "antd"; +import type { ICellRendererParams } from "ag-grid-community"; +import type { MenuProps } from "antd"; +import type { FacetGridRow } from "../mappers"; +import { useFacetCellStyles } from "./facet-cell-styles"; + +export interface FacetTreeActionsCellParams + extends ICellRendererParams { + hasUnsavedChanges?: boolean; + onEdit: (row: FacetGridRow) => void; + onDuplicate: (row: FacetGridRow) => void; + onDelete: (row: FacetGridRow) => void; + onBlockedDelete?: () => void; +} + +export function FacetTreeActionsCell(params: FacetTreeActionsCellParams) { + const { styles } = useFacetCellStyles(); + const row = params.data; + if (!row) { + return null; + } + + const deleteDisabled = params.hasUnsavedChanges ?? false; + const rowTestId = row.slug ?? row.id; + const items: MenuProps["items"] = [ + { + key: "edit", + label: "Edit", + icon: , + "data-testid": `facets-row-action-edit-${rowTestId}`, + }, + ]; + + items.push({ + key: "duplicate", + label: "Duplicate", + icon: , + }); + + items.push( + { + key: "delete", + label: deleteDisabled ? "Save or discard changes first" : "Delete", + icon: , + danger: true, + disabled: deleteDisabled, + }, + ); + + return ( +
    + { + if (key === "edit") { + params.onEdit(row); + } + if (key === "duplicate") { + params.onDuplicate(row); + } + if (key === "delete") { + if (deleteDisabled) { + params.onBlockedDelete?.(); + return; + } + params.onDelete(row); + } + }, + }} + > +
    + ); +} diff --git a/admin/src/domains/discovery/facets/components/facet-value-candidates-grid.tsx b/admin/src/domains/discovery/facets/components/facet-value-candidates-grid.tsx new file mode 100644 index 000000000..7b4727a4b --- /dev/null +++ b/admin/src/domains/discovery/facets/components/facet-value-candidates-grid.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AgGridReact } from "ag-grid-react"; +import type { + ColDef, + GetRowIdParams, + ICellRendererParams, + RowSelectionOptions, + SelectionChangedEvent, + SortChangedEvent, +} from "ag-grid-community"; +import { Alert, Button, Input, Space, Typography } from "antd"; +import { createStyles } from "antd-style"; +import { LuSearch as SearchOutlined } from "react-icons/lu"; +import { + FacetType, + FacetValueCandidateOrderField, + FacetValueCandidateType, + SortDirection, + type ApiFacetValueCandidateOrderByInput, + type ApiFacetValueCandidateWhereInput, +} from "@/graphql/types"; +import { useAgGridTheme } from "@/hooks"; +import { + CursorPagination, + useRelayCursorPagination, +} from "@/ui-kit/cursor-pagination"; +import { useFacetValueCandidates } from "../hooks"; +import type { FacetValueCandidateFields } from "../graphql/operation-types"; + +export interface FacetValueCandidateFormValue { + id: string; + handle: string; + label: string; + sourceHandle: string; +} + +export interface FacetValueCandidatesGridProps { + facetType: FacetType; + sourceHandle?: string | null; + sourceHandles?: string[]; + facetId?: string; + value: FacetValueCandidateFormValue[]; + onChange: (value: FacetValueCandidateFormValue[]) => void; + height?: number; + pageSize?: number; +} + +const PAGE_SIZE = 20; +const SEARCH_DEBOUNCE_MS = 275; + +const SUPPORTED_FACET_TYPES = new Set([ + FacetType.Tag, + FacetType.Option, + FacetType.Feature, +]); + +const useStyles = createStyles(({ token }) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: 12, + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + state: { + color: token.colorTextSecondary, + fontSize: 13, + padding: "4px 0 8px", + }, + grid: { + height: 336, + minHeight: 336, + }, + pagination: { + paddingTop: 0, + }, +})); + +function toCandidateType(facetType: FacetType): FacetValueCandidateType | null { + if (facetType === FacetType.Tag) return FacetValueCandidateType.Tag; + if (facetType === FacetType.Option) return FacetValueCandidateType.Option; + if (facetType === FacetType.Feature) return FacetValueCandidateType.Feature; + return null; +} + +function toFormValue( + candidate: FacetValueCandidateFields, +): FacetValueCandidateFormValue { + return { + id: candidate.id, + handle: candidate.handle, + label: candidate.label, + sourceHandle: candidate.sourceHandle, + }; +} + +function buildSearchWhere( + search: string, +): ApiFacetValueCandidateWhereInput | null { + const value = search.trim(); + if (!value) return null; + + return { + _or: [ + { label: { _containsi: value } }, + { handle: { _containsi: value } }, + ], + }; +} + +function buildOrderBy( + sortModel: ApiFacetValueCandidateOrderByInput[], +): ApiFacetValueCandidateOrderByInput[] { + return [ + ...sortModel, + { field: FacetValueCandidateOrderField.Id, direction: SortDirection.Asc }, + ]; +} + +export function FacetValueCandidatesGrid({ + facetType, + sourceHandle, + sourceHandles, + facetId, + value, + onChange, + height, + pageSize = PAGE_SIZE, +}: FacetValueCandidatesGridProps) { + const { styles } = useStyles(); + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [sortModel, setSortModel] = useState([ + { field: FacetValueCandidateOrderField.Label, direction: SortDirection.Asc }, + ]); + + useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedSearch(search), + SEARCH_DEBOUNCE_MS, + ); + return () => window.clearTimeout(timeout); + }, [search]); + + const selectionById = useMemo( + () => new Map(value.map((candidate) => [candidate.id, candidate])), + [value], + ); + const effectiveSourceHandles = useMemo(() => { + if (sourceHandles) return sourceHandles.filter(Boolean); + return sourceHandle ? [sourceHandle] : []; + }, [sourceHandle, sourceHandles]); + const candidateType = toCandidateType(facetType); + const canLoadCandidates = + Boolean(candidateType) && + SUPPORTED_FACET_TYPES.has(facetType) && + (effectiveSourceHandles.length > 0 || Boolean(facetId)); + const searchWhere = useMemo( + () => buildSearchWhere(debouncedSearch), + [debouncedSearch], + ); + const orderBy = useMemo(() => buildOrderBy(sortModel), [sortModel]); + const resetKey = `${facetType}:${facetId ?? ""}:${effectiveSourceHandles.join(",")}:${debouncedSearch}:${JSON.stringify(sortModel)}`; + const pagination = useRelayCursorPagination({ + defaultPageSize: pageSize, + resetKey, + }); + + const { candidates, totalCount, pageInfo, loading, error, refetch } = + useFacetValueCandidates({ + ...pagination.variables, + where: searchWhere, + orderBy, + meta: + canLoadCandidates && candidateType + ? { + candidateType, + ...(effectiveSourceHandles.length > 0 + ? { sourceHandles: effectiveSourceHandles } + : {}), + ...(facetId ? { facetId } : {}), + } + : null, + skip: !canLoadCandidates, + }); + + const columnDefs = useMemo[]>( + () => [ + { + field: "label", + headerName: "Value", + flex: 1, + minWidth: 180, + sort: "asc", + cellRenderer: ({ + data, + }: ICellRendererParams) => + data ? ( + + {data.label} + + ) : null, + }, + { + field: "sourceHandle", + headerName: "Source", + width: 140, + }, + { + field: "handle", + headerName: "Handle", + hide: true, + }, + ], + [], + ); + + const rowSelection = useMemo( + () => ({ + mode: "multiRow", + checkboxes: true, + headerCheckbox: true, + enableClickSelection: true, + enableSelectionWithoutKeys: true, + }), + [], + ); + + const syncGridSelection = useCallback(() => { + const api = gridRef.current?.api; + if (!api) return; + + api.forEachNode((node) => { + if (!node.data) return; + node.setSelected(selectionById.has(node.data.id)); + }); + }, [selectionById]); + + useEffect(() => { + syncGridSelection(); + }, [candidates, syncGridSelection]); + + const handleSelectionChanged = useCallback( + (event: SelectionChangedEvent) => { + const nextSelection = new Map(selectionById); + const currentPageIds = new Set(candidates.map((candidate) => candidate.id)); + + for (const id of currentPageIds) { + nextSelection.delete(id); + } + + for (const row of event.api.getSelectedRows()) { + nextSelection.set(row.id, toFormValue(row)); + } + + onChange([...nextSelection.values()]); + }, + [candidates, onChange, selectionById], + ); + + const handleSortChanged = useCallback( + (event: SortChangedEvent) => { + const sortedColumns = event.api + .getColumnState() + .filter((column) => column.sort != null) + .sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0)); + const labelSort = sortedColumns.find((column) => column.colId === "label"); + + setSortModel([ + { + field: FacetValueCandidateOrderField.Label, + direction: + labelSort?.sort === "desc" ? SortDirection.Desc : SortDirection.Asc, + }, + ]); + }, + [], + ); + + if (!sourceHandle && effectiveSourceHandles.length === 0 && !facetId) { + return ( +
    + Select a source to load available values. +
    + ); + } + + if (!SUPPORTED_FACET_TYPES.has(facetType)) { + return null; + } + + return ( +
    +
    + } + placeholder="Search filter values" + value={search} + onChange={(event) => setSearch(event.target.value)} + data-testid="facet-value-candidates-search-input" + /> +
    + + {error ? ( + void refetch()}> + Retry + + } + /> + ) : null} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={candidates} + columnDefs={columnDefs} + getRowId={(params: GetRowIdParams) => + params.data.id + } + rowHeight={44} + headerHeight={38} + rowSelection={rowSelection} + selectionColumnDef={{ + cellStyle: { display: "flex", alignItems: "center" }, + }} + suppressCellFocus + suppressMovableColumns + loading={loading} + noRowsOverlayComponent={() => ( + + No values found + + )} + defaultColDef={{ + sortable: true, + resizable: false, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }} + onGridReady={syncGridSelection} + onSelectionChanged={handleSelectionChanged} + onSortChanged={handleSortChanged} + /> +
    + +
    + pagination.goToNextPage(pageInfo)} + onPrev={() => pagination.goToPreviousPage(pageInfo)} + onPageSizeChange={pagination.setPageSize} + /> +
    +
    + ); +} diff --git a/admin/src/domains/discovery/facets/components/facet-values-cell.tsx b/admin/src/domains/discovery/facets/components/facet-values-cell.tsx new file mode 100644 index 000000000..526336ac4 --- /dev/null +++ b/admin/src/domains/discovery/facets/components/facet-values-cell.tsx @@ -0,0 +1,54 @@ +import { Tag, Typography } from "antd"; +import type { ICellRendererParams } from "ag-grid-community"; +import type { FacetGridRow } from "../mappers"; +import { isDiscreteFacetType } from "../mappers"; +import { useFacetCellStyles } from "./facet-cell-styles"; + +const MAX_VISIBLE_VALUES = 4; + +export function FacetValuesCell(params: ICellRendererParams) { + const { styles } = useFacetCellStyles(); + const row = params.data; + if (!row) { + return null; + } + + if (!isDiscreteFacetType(row.facetType)) { + return Automatic; + } + + const values = [...row.values].sort( + (left, right) => left.sortIndex - right.sortIndex, + ); + const visibleValues = values.slice(0, MAX_VISIBLE_VALUES); + const hiddenCount = values.length - visibleValues.length; + + if (values.length === 0) { + return No values; + } + + return ( +
    + {visibleValues.map((value) => ( + + + {value.name} + + + ))} + {hiddenCount > 0 ? ( + +{hiddenCount} + ) : null} +
    + ); +} diff --git a/admin/src/domains/discovery/facets/components/index.ts b/admin/src/domains/discovery/facets/components/index.ts new file mode 100644 index 000000000..54c30955d --- /dev/null +++ b/admin/src/domains/discovery/facets/components/index.ts @@ -0,0 +1,5 @@ +export * from "./facet-name-cell"; +export * from "./facet-tree-actions-cell"; +export * from "./facet-select-cell"; +export * from "./facet-values-cell"; +export * from "./facet-scopes-settings-paper"; diff --git a/admin/src/domains/discovery/facets/graphql/fragments.ts b/admin/src/domains/discovery/facets/graphql/fragments.ts new file mode 100644 index 000000000..dbfcf6bbb --- /dev/null +++ b/admin/src/domains/discovery/facets/graphql/fragments.ts @@ -0,0 +1,62 @@ +import { gql } from "@apollo/client"; +import { FILE_FRAGMENT } from "@/domains/inventory/graphql/shared-fragments"; + +export const FACET_SWATCH_FRAGMENT = gql` + fragment FacetSwatchFields on FacetSwatch { + id + swatchType + colorOne + colorTwo + file { + ...FileFields + } + metadata + } + ${FILE_FRAGMENT} +`; + +export const FACET_VALUE_GRID_FRAGMENT = gql` + fragment FacetValueGridFields on FacetValue { + id + label + handle + kind + sortIndex + enabled + parent { + id + label + handle + } + sourceValues { + id + label + handle + } + swatch { + ...FacetSwatchFields + } + } + ${FACET_SWATCH_FRAGMENT} +`; + +export const FACET_GRID_FRAGMENT = gql` + fragment FacetGridFields on Facet { + id + label + slug + facetType + uiType + scopes + selectionMode + lexoRank + sources { + handle + name + } + values { + ...FacetValueGridFields + } + } + ${FACET_VALUE_GRID_FRAGMENT} +`; diff --git a/admin/src/domains/discovery/facets/graphql/index.ts b/admin/src/domains/discovery/facets/graphql/index.ts new file mode 100644 index 000000000..176b6f88a --- /dev/null +++ b/admin/src/domains/discovery/facets/graphql/index.ts @@ -0,0 +1,3 @@ +export * from "./fragments"; +export * from "./queries"; +export * from "./mutations"; diff --git a/admin/src/domains/discovery/facets/graphql/mutations.ts b/admin/src/domains/discovery/facets/graphql/mutations.ts new file mode 100644 index 000000000..9273e3a63 --- /dev/null +++ b/admin/src/domains/discovery/facets/graphql/mutations.ts @@ -0,0 +1,220 @@ +import { gql } from "@apollo/client"; +import { USER_ERROR_FRAGMENT } from "@/domains/inventory/graphql/shared-fragments"; +import { + FACET_GRID_FRAGMENT, + FACET_SWATCH_FRAGMENT, + FACET_VALUE_GRID_FRAGMENT, +} from "./fragments"; + +export const FACET_CREATE_MUTATION = gql` + mutation FacetCreate($input: FacetCreateInput!) { + listingMutation { + facetCreate(input: $input) { + facet { + ...FacetGridFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_UPDATE_MUTATION = gql` + mutation FacetUpdate($input: FacetUpdateInput!) { + listingMutation { + facetUpdate(input: $input) { + facet { + ...FacetGridFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_SCOPES_UPDATE_MUTATION = gql` + mutation FacetScopesUpdate($input: FacetScopesUpdateInput!) { + listingMutation { + facetScopesUpdate(input: $input) { + facets { + ...FacetGridFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_DELETE_MUTATION = gql` + mutation FacetDelete($input: FacetDeleteInput!) { + listingMutation { + facetDelete(input: $input) { + deletedFacetId + userErrors { + ...UserErrorFields + } + } + } + } + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_MOVE_MUTATION = gql` + mutation FacetMove($input: FacetMoveInput!) { + listingMutation { + facetMove(input: $input) { + facet { + ...FacetGridFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_VALUE_CREATE_MUTATION = gql` + mutation FacetValueCreate($input: FacetValueCreateInput!) { + listingMutation { + facetValueCreate(input: $input) { + facetValue { + ...FacetValueGridFields + facet { + id + } + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_VALUE_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_VALUE_UPDATE_MUTATION = gql` + mutation FacetValueUpdate($input: FacetValueUpdateInput!) { + listingMutation { + facetValueUpdate(input: $input) { + facetValue { + ...FacetValueGridFields + facet { + id + } + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_VALUE_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_VALUE_DELETE_MUTATION = gql` + mutation FacetValueDelete($input: FacetValueDeleteInput!) { + listingMutation { + facetValueDelete(input: $input) { + deletedFacetValueId + userErrors { + ...UserErrorFields + } + } + } + } + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_VALUE_MERGE_MUTATION = gql` + mutation FacetValueMerge($input: FacetValueMergeInput!) { + listingMutation { + facetValueMerge(input: $input) { + facetValue { + ...FacetValueGridFields + facet { + id + } + } + sourceValues { + ...FacetValueGridFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_VALUE_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_VALUE_UNMERGE_MUTATION = gql` + mutation FacetValueUnmerge($input: FacetValueUnmergeInput!) { + listingMutation { + facetValueUnmerge(input: $input) { + sourceValues { + ...FacetValueGridFields + } + affectedGroupValues { + ...FacetValueGridFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_VALUE_GRID_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_SWATCH_CREATE_MUTATION = gql` + mutation FacetSwatchCreate($input: FacetSwatchCreateInput!) { + listingMutation { + facetSwatchCreate(input: $input) { + facetSwatch { + ...FacetSwatchFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_SWATCH_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const FACET_SWATCH_UPDATE_MUTATION = gql` + mutation FacetSwatchUpdate($input: FacetSwatchUpdateInput!) { + listingMutation { + facetSwatchUpdate(input: $input) { + facetSwatch { + ...FacetSwatchFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${FACET_SWATCH_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; diff --git a/admin/src/domains/discovery/facets/graphql/operation-types.ts b/admin/src/domains/discovery/facets/graphql/operation-types.ts new file mode 100644 index 000000000..6d04c3255 --- /dev/null +++ b/admin/src/domains/discovery/facets/graphql/operation-types.ts @@ -0,0 +1,342 @@ +import type { + ApiFacet, + ApiFacetCreateInput, + ApiFacetCreatePayload, + ApiFacetDeleteInput, + ApiFacetDeletePayload, + ApiFacetMoveInput, + ApiFacetMovePayload, + ApiFacetScopesUpdateInput, + ApiFacetScopesUpdatePayload, + ApiFacetSource, + ApiFacetSourceCandidate, + ApiFacetSourceCandidateConnection, + ApiFacetSourceCandidateOrderByInput, + ApiFacetSourceCandidateWhereInput, + ApiFacetSwatch, + ApiFacetSwatchCreateInput, + ApiFacetSwatchCreatePayload, + ApiFacetSwatchUpdateInput, + ApiFacetSwatchUpdatePayload, + ApiFacetUpdateInput, + ApiFacetUpdatePayload, + ApiFacetValue, + ApiFacetValueCandidate, + ApiFacetValueCandidateConnection, + ApiFacetValueCandidateOrderByInput, + ApiFacetValueCandidateWhereInput, + ApiFacetValueCandidatesMetaInput, + ApiFacetValueCreateInput, + ApiFacetValueCreatePayload, + ApiFacetValueDeleteInput, + ApiFacetValueDeletePayload, + ApiFacetValueMergeInput, + ApiFacetValueMergePayload, + ApiFacetValueUnmergeInput, + ApiFacetValueUnmergePayload, + ApiFacetValueUpdateInput, + ApiFacetValueUpdatePayload, + ApiFile, + ApiGenericUserError, +} from "@/graphql/types"; + +export type FacetSwatchFields = Pick< + ApiFacetSwatch, + "id" | "swatchType" | "colorOne" | "colorTwo" | "metadata" +> & { + file: ApiFile | null; +}; + +export type FacetValueGridFields = Pick< + ApiFacetValue, + "id" | "label" | "handle" | "kind" | "sortIndex" | "enabled" +> & { + parent: Pick | null; + sourceValues: Array>; + swatch: FacetSwatchFields | null; +}; + +export type FacetGridFields = Pick< + ApiFacet, + | "id" + | "label" + | "slug" + | "facetType" + | "uiType" + | "scopes" + | "selectionMode" + | "lexoRank" +> & { + sources: Array>; + values: FacetValueGridFields[]; +}; + +export type FacetValueDetailsFields = FacetValueGridFields & { + facet: Pick; +}; + +export interface FacetGridQueryData { + listingQuery: { + facets: FacetGridFields[]; + }; +} + +export interface FacetDetailsQueryData { + listingQuery: { + facet: FacetGridFields | null; + }; +} + +export interface FacetDetailsQueryVariables { + id: string; +} + +export interface FacetValueDetailsQueryData { + listingQuery: { + facetValue: FacetValueDetailsFields | null; + }; +} + +export interface FacetValueDetailsQueryVariables { + id: string; +} + +export type FacetSourceCandidateFields = Pick< + ApiFacetSourceCandidate, + "id" | "facetType" | "handle" | "name" +>; + +export type FacetSourceCandidateConnectionFields = Pick< + ApiFacetSourceCandidateConnection, + "pageInfo" | "totalCount" +> & { + edges: Array<{ + cursor: string; + node: FacetSourceCandidateFields; + }>; +}; + +export interface FacetSourceCandidatesQueryData { + listingQuery: { + facetSourceCandidates: FacetSourceCandidateConnectionFields; + }; +} + +export interface FacetSourceCandidatesQueryVariables { + first?: number | null; + after?: string | null; + last?: number | null; + before?: string | null; + where?: ApiFacetSourceCandidateWhereInput | null; + orderBy?: ApiFacetSourceCandidateOrderByInput[] | null; +} + +export type FacetValueCandidateFields = Pick< + ApiFacetValueCandidate, + "id" | "facetType" | "sourceHandle" | "handle" | "label" +>; + +export type FacetValueCandidateConnectionFields = Pick< + ApiFacetValueCandidateConnection, + "pageInfo" | "totalCount" +> & { + edges: Array<{ + cursor: string; + node: FacetValueCandidateFields; + }>; +}; + +export interface FacetValueCandidatesQueryData { + listingQuery: { + facetValueCandidates: FacetValueCandidateConnectionFields; + }; +} + +export interface FacetValueCandidatesQueryVariables { + first?: number | null; + after?: string | null; + last?: number | null; + before?: string | null; + where?: ApiFacetValueCandidateWhereInput | null; + orderBy?: ApiFacetValueCandidateOrderByInput[] | null; + meta: ApiFacetValueCandidatesMetaInput; +} + +export interface FacetCreateMutationData { + listingMutation: { + facetCreate: Omit & { + facet: FacetGridFields | null; + }; + }; +} + +export interface FacetCreateMutationVariables { + input: ApiFacetCreateInput; +} + +export interface FacetUpdateMutationData { + listingMutation: { + facetUpdate: Omit & { + facet: FacetGridFields | null; + }; + }; +} + +export interface FacetUpdateMutationVariables { + input: ApiFacetUpdateInput; +} + +export interface FacetScopesUpdateMutationData { + listingMutation: { + facetScopesUpdate: Omit & { + facets: FacetGridFields[]; + }; + }; +} + +export interface FacetScopesUpdateMutationVariables { + input: ApiFacetScopesUpdateInput; +} + +export interface FacetDeleteMutationData { + listingMutation: { + facetDelete: ApiFacetDeletePayload; + }; +} + +export interface FacetDeleteMutationVariables { + input: ApiFacetDeleteInput; +} + +export interface FacetMoveMutationData { + listingMutation: { + facetMove: Omit & { + facet: FacetGridFields | null; + }; + }; +} + +export interface FacetMoveMutationVariables { + input: ApiFacetMoveInput; +} + +export interface FacetValueCreateMutationData { + listingMutation: { + facetValueCreate: Omit & { + facetValue: (FacetValueGridFields & { facet: Pick }) | null; + }; + }; +} + +export interface FacetValueCreateMutationVariables { + input: ApiFacetValueCreateInput; +} + +export interface FacetValueUpdateMutationData { + listingMutation: { + facetValueUpdate: Omit & { + facetValue: (FacetValueGridFields & { facet: Pick }) | null; + }; + }; +} + +export interface FacetValueUpdateMutationVariables { + input: ApiFacetValueUpdateInput; +} + +export interface FacetValueDeleteMutationData { + listingMutation: { + facetValueDelete: ApiFacetValueDeletePayload; + }; +} + +export interface FacetValueDeleteMutationVariables { + input: ApiFacetValueDeleteInput; +} + +export interface FacetValueMergeMutationData { + listingMutation: { + facetValueMerge: Omit< + ApiFacetValueMergePayload, + "facetValue" | "sourceValues" + > & { + facetValue: (FacetValueGridFields & { facet: Pick }) | null; + sourceValues: FacetValueGridFields[]; + }; + }; +} + +export interface FacetValueMergeMutationVariables { + input: ApiFacetValueMergeInput; +} + +export interface FacetValueUnmergeMutationData { + listingMutation: { + facetValueUnmerge: Omit< + ApiFacetValueUnmergePayload, + "sourceValues" | "affectedGroupValues" + > & { + sourceValues: FacetValueGridFields[]; + affectedGroupValues: FacetValueGridFields[]; + }; + }; +} + +export interface FacetValueUnmergeMutationVariables { + input: ApiFacetValueUnmergeInput; +} + +export interface FacetSwatchCreateMutationData { + listingMutation: { + facetSwatchCreate: Omit & { + facetSwatch: FacetSwatchFields | null; + }; + }; +} + +export interface FacetSwatchCreateMutationVariables { + input: ApiFacetSwatchCreateInput; +} + +export interface FacetSwatchUpdateMutationData { + listingMutation: { + facetSwatchUpdate: Omit & { + facetSwatch: FacetSwatchFields | null; + }; + }; +} + +export interface FacetSwatchUpdateMutationVariables { + input: ApiFacetSwatchUpdateInput; +} + +export interface FacetMutationResult { + facet: FacetGridFields | null; + userErrors: ApiGenericUserError[]; +} + +export interface FacetValueMutationResult { + facetValue: + | (FacetValueGridFields & { facet?: Pick | null }) + | null; + userErrors: ApiGenericUserError[]; +} + +export interface FacetValueMergeMutationResult { + facetValue: + | (FacetValueGridFields & { facet?: Pick | null }) + | null; + sourceValues: FacetValueGridFields[]; + userErrors: ApiGenericUserError[]; +} + +export interface FacetValueUnmergeMutationResult { + sourceValues: FacetValueGridFields[]; + affectedGroupValues: FacetValueGridFields[]; + userErrors: ApiGenericUserError[]; +} + +export interface FacetSwatchMutationResult { + facetSwatch: FacetSwatchFields | null; + userErrors: ApiGenericUserError[]; +} diff --git a/admin/src/domains/discovery/facets/graphql/queries.ts b/admin/src/domains/discovery/facets/graphql/queries.ts new file mode 100644 index 000000000..92ac51702 --- /dev/null +++ b/admin/src/domains/discovery/facets/graphql/queries.ts @@ -0,0 +1,121 @@ +import { gql } from "@apollo/client"; +import { FACET_GRID_FRAGMENT, FACET_VALUE_GRID_FRAGMENT } from "./fragments"; + +export const FACET_GRID_QUERY = gql` + query FacetGrid { + listingQuery { + facets { + ...FacetGridFields + } + } + } + ${FACET_GRID_FRAGMENT} +`; + +export const FACET_DETAILS_QUERY = gql` + query FacetDetails($id: ID!) { + listingQuery { + facet(id: $id) { + ...FacetGridFields + } + } + } + ${FACET_GRID_FRAGMENT} +`; + +export const FACET_VALUE_DETAILS_QUERY = gql` + query FacetValueDetails($id: ID!) { + listingQuery { + facetValue(id: $id) { + ...FacetValueGridFields + facet { + id + label + facetType + } + } + } + } + ${FACET_VALUE_GRID_FRAGMENT} +`; + +export const FACET_SOURCE_CANDIDATES_QUERY = gql` + query FacetSourceCandidates( + $first: Int + $after: String + $last: Int + $before: String + $where: FacetSourceCandidateWhereInput + $orderBy: [FacetSourceCandidateOrderByInput!] + ) { + listingQuery { + facetSourceCandidates( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + id + facetType + handle + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } +`; + +export const FACET_VALUE_CANDIDATES_QUERY = gql` + query FacetValueCandidates( + $first: Int + $after: String + $last: Int + $before: String + $where: FacetValueCandidateWhereInput + $orderBy: [FacetValueCandidateOrderByInput!] + $meta: FacetValueCandidatesMetaInput! + ) { + listingQuery { + facetValueCandidates( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + meta: $meta + ) { + edges { + cursor + node { + id + facetType + sourceHandle + handle + label + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } +`; diff --git a/admin/src/domains/discovery/facets/hooks/index.ts b/admin/src/domains/discovery/facets/hooks/index.ts new file mode 100644 index 000000000..782b8dd68 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/index.ts @@ -0,0 +1,16 @@ +export * from "./use-facets"; +export * from "./use-facet"; +export * from "./use-facet-source-candidates"; +export * from "./use-facet-value-candidates"; +export * from "./use-facet-value"; +export * from "./use-create-facet"; +export * from "./use-update-facet"; +export * from "./use-update-facet-scopes"; +export * from "./use-move-facet"; +export * from "./use-delete-facet"; +export * from "./use-create-facet-value"; +export * from "./use-update-facet-value"; +export * from "./use-delete-facet-value"; +export * from "./use-merge-facet-values"; +export * from "./use-unmerge-facet-values"; +export * from "./use-upsert-facet-swatch"; diff --git a/admin/src/domains/discovery/facets/hooks/use-create-facet-value.ts b/admin/src/domains/discovery/facets/hooks/use-create-facet-value.ts new file mode 100644 index 000000000..37982b214 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-create-facet-value.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetValueCreateInput } from "@/graphql/types"; +import { FACET_GRID_QUERY, FACET_VALUE_CREATE_MUTATION } from "../graphql"; +import type { + FacetValueCreateMutationData, + FacetValueCreateMutationVariables, + FacetValueMutationResult, +} from "../graphql/operation-types"; + +interface UseCreateFacetValueReturn { + createFacetValue: ( + input: ApiFacetValueCreateInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useCreateFacetValue(): UseCreateFacetValueReturn { + const [createFacetValueMutation, { loading, error, reset }] = useMutation< + FacetValueCreateMutationData, + FacetValueCreateMutationVariables + >(FACET_VALUE_CREATE_MUTATION); + + const createFacetValue = useCallback( + async ( + input: ApiFacetValueCreateInput, + ): Promise => { + try { + const result = await createFacetValueMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetValueCreate; + + return { + facetValue: payload?.facetValue ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + facetValue: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [createFacetValueMutation], + ); + + return { createFacetValue, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-create-facet.ts b/admin/src/domains/discovery/facets/hooks/use-create-facet.ts new file mode 100644 index 000000000..028e4f9ed --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-create-facet.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetCreateInput } from "@/graphql/types"; +import { FACET_CREATE_MUTATION, FACET_GRID_QUERY } from "../graphql"; +import type { + FacetCreateMutationData, + FacetCreateMutationVariables, + FacetMutationResult, +} from "../graphql/operation-types"; + +interface UseCreateFacetReturn { + createFacet: (input: ApiFacetCreateInput) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useCreateFacet(): UseCreateFacetReturn { + const [createFacetMutation, { loading, error, reset }] = useMutation< + FacetCreateMutationData, + FacetCreateMutationVariables + >(FACET_CREATE_MUTATION); + + const createFacet = useCallback( + async (input: ApiFacetCreateInput): Promise => { + try { + const result = await createFacetMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetCreate; + + return { + facet: payload?.facet ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + facet: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [createFacetMutation], + ); + + return { createFacet, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-delete-facet-value.ts b/admin/src/domains/discovery/facets/hooks/use-delete-facet-value.ts new file mode 100644 index 000000000..19fbea948 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-delete-facet-value.ts @@ -0,0 +1,64 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiFacetValueDeleteInput, + ApiGenericUserError, +} from "@/graphql/types"; +import { FACET_GRID_QUERY, FACET_VALUE_DELETE_MUTATION } from "../graphql"; +import type { + FacetValueDeleteMutationData, + FacetValueDeleteMutationVariables, +} from "../graphql/operation-types"; + +interface DeleteFacetValueResult { + deletedFacetValueId: string | null; + userErrors: ApiGenericUserError[]; +} + +interface UseDeleteFacetValueReturn { + deleteFacetValue: ( + input: ApiFacetValueDeleteInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useDeleteFacetValue(): UseDeleteFacetValueReturn { + const [deleteFacetValueMutation, { loading, error, reset }] = useMutation< + FacetValueDeleteMutationData, + FacetValueDeleteMutationVariables + >(FACET_VALUE_DELETE_MUTATION); + + const deleteFacetValue = useCallback( + async ( + input: ApiFacetValueDeleteInput, + ): Promise => { + try { + const result = await deleteFacetValueMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetValueDelete; + + return { + deletedFacetValueId: payload?.deletedFacetValueId ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + deletedFacetValueId: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [deleteFacetValueMutation], + ); + + return { deleteFacetValue, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-delete-facet.ts b/admin/src/domains/discovery/facets/hooks/use-delete-facet.ts new file mode 100644 index 000000000..f5a10e1e1 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-delete-facet.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetDeleteInput, ApiGenericUserError } from "@/graphql/types"; +import { FACET_DELETE_MUTATION, FACET_GRID_QUERY } from "../graphql"; +import type { + FacetDeleteMutationData, + FacetDeleteMutationVariables, +} from "../graphql/operation-types"; + +interface DeleteFacetResult { + deletedFacetId: string | null; + userErrors: ApiGenericUserError[]; +} + +interface UseDeleteFacetReturn { + deleteFacet: (input: ApiFacetDeleteInput) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useDeleteFacet(): UseDeleteFacetReturn { + const [deleteFacetMutation, { loading, error, reset }] = useMutation< + FacetDeleteMutationData, + FacetDeleteMutationVariables + >(FACET_DELETE_MUTATION); + + const deleteFacet = useCallback( + async (input: ApiFacetDeleteInput): Promise => { + try { + const result = await deleteFacetMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetDelete; + + return { + deletedFacetId: payload?.deletedFacetId ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + deletedFacetId: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [deleteFacetMutation], + ); + + return { deleteFacet, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-facet-source-candidates.ts b/admin/src/domains/discovery/facets/hooks/use-facet-source-candidates.ts new file mode 100644 index 000000000..99cab8ce4 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-facet-source-candidates.ts @@ -0,0 +1,70 @@ +"use client"; + +import type { + ApiFacetSourceCandidateOrderByInput, + ApiFacetSourceCandidateWhereInput, + ApiPageInfo, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { FACET_SOURCE_CANDIDATES_QUERY } from "../graphql"; +import type { + FacetSourceCandidateConnectionFields, + FacetSourceCandidateFields, + FacetSourceCandidatesQueryData, + FacetSourceCandidatesQueryVariables, +} from "../graphql/operation-types"; + +export interface UseFacetSourceCandidatesOptions + extends RelayCursorPaginationVariables { + where?: ApiFacetSourceCandidateWhereInput | null; + orderBy?: ApiFacetSourceCandidateOrderByInput[] | null; + skip?: boolean; +} + +export interface UseFacetSourceCandidatesReturn { + candidates: FacetSourceCandidateFields[]; + connection: FacetSourceCandidateConnectionFields | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useFacetSourceCandidatesPageQuery( + options: UseFacetSourceCandidatesOptions = {}, +): UseFacetSourceCandidatesReturn { + const { + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + skip = false, + } = options; + + const result = useRelayConnectionQuery< + FacetSourceCandidatesQueryData, + FacetSourceCandidatesQueryVariables, + FacetSourceCandidateFields, + FacetSourceCandidateConnectionFields + >({ + query: FACET_SOURCE_CANDIDATES_QUERY, + variables: { first, after, last, before, where, orderBy }, + skip, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.listingQuery.facetSourceCandidates, + }); + + return { + candidates: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-facet-value-candidates.ts b/admin/src/domains/discovery/facets/hooks/use-facet-value-candidates.ts new file mode 100644 index 000000000..abaf5bb50 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-facet-value-candidates.ts @@ -0,0 +1,84 @@ +"use client"; + +import type { + ApiFacetValueCandidateOrderByInput, + ApiFacetValueCandidateWhereInput, + ApiFacetValueCandidatesMetaInput, + ApiPageInfo, +} from "@/graphql/types"; +import { FacetValueCandidateType } from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { FACET_VALUE_CANDIDATES_QUERY } from "../graphql"; +import type { + FacetValueCandidateConnectionFields, + FacetValueCandidateFields, + FacetValueCandidatesQueryData, + FacetValueCandidatesQueryVariables, +} from "../graphql/operation-types"; + +export interface UseFacetValueCandidatesOptions + extends RelayCursorPaginationVariables { + where?: ApiFacetValueCandidateWhereInput | null; + orderBy?: ApiFacetValueCandidateOrderByInput[] | null; + meta?: ApiFacetValueCandidatesMetaInput | null; + skip?: boolean; +} + +export interface UseFacetValueCandidatesReturn { + candidates: FacetValueCandidateFields[]; + connection: FacetValueCandidateConnectionFields | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useFacetValueCandidates( + options: UseFacetValueCandidatesOptions = {}, +): UseFacetValueCandidatesReturn { + const { + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + meta, + skip = false, + } = options; + + const shouldSkip = skip || !meta; + + const result = useRelayConnectionQuery< + FacetValueCandidatesQueryData, + FacetValueCandidatesQueryVariables, + FacetValueCandidateFields, + FacetValueCandidateConnectionFields + >({ + query: FACET_VALUE_CANDIDATES_QUERY, + variables: { + first, + after, + last, + before, + where, + orderBy, + meta: meta ?? { candidateType: FacetValueCandidateType.Tag }, + }, + skip: shouldSkip, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.listingQuery.facetValueCandidates, + }); + + return { + candidates: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-facet-value.ts b/admin/src/domains/discovery/facets/hooks/use-facet-value.ts new file mode 100644 index 000000000..cfc61c630 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-facet-value.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { FACET_VALUE_DETAILS_QUERY } from "../graphql"; +import type { + FacetValueDetailsFields, + FacetValueDetailsQueryData, + FacetValueDetailsQueryVariables, +} from "../graphql/operation-types"; + +interface UseFacetValueReturn { + facetValue: FacetValueDetailsFields | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useFacetValue(id?: string | null): UseFacetValueReturn { + const { data, previousData, loading, error, refetch } = useQuery< + FacetValueDetailsQueryData, + FacetValueDetailsQueryVariables + >(FACET_VALUE_DETAILS_QUERY, { + variables: { id: id ?? "" }, + skip: !id, + fetchPolicy: "cache-and-network", + }); + const effectiveData = data ?? previousData; + + return { + facetValue: effectiveData?.listingQuery.facetValue ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-facet.ts b/admin/src/domains/discovery/facets/hooks/use-facet.ts new file mode 100644 index 000000000..5fc622633 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-facet.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { FACET_DETAILS_QUERY } from "../graphql"; +import type { + FacetDetailsQueryData, + FacetDetailsQueryVariables, + FacetGridFields, +} from "../graphql/operation-types"; + +interface UseFacetReturn { + facet: FacetGridFields | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useFacet(id?: string | null): UseFacetReturn { + const { data, previousData, loading, error, refetch } = useQuery< + FacetDetailsQueryData, + FacetDetailsQueryVariables + >(FACET_DETAILS_QUERY, { + variables: { id: id ?? "" }, + skip: !id, + fetchPolicy: "cache-and-network", + }); + const effectiveData = data ?? previousData; + + return { + facet: effectiveData?.listingQuery.facet ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-facets.ts b/admin/src/domains/discovery/facets/hooks/use-facets.ts new file mode 100644 index 000000000..893f076d8 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-facets.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { FACET_GRID_QUERY } from "../graphql"; +import type { + FacetGridFields, + FacetGridQueryData, +} from "../graphql/operation-types"; + +export interface UseFacetsReturn { + facets: FacetGridFields[]; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useFacets(): UseFacetsReturn { + const { data, previousData, loading, error, refetch } = + useQuery(FACET_GRID_QUERY, { + fetchPolicy: "cache-and-network", + }); + + const effectiveData = data ?? previousData; + + return { + facets: effectiveData?.listingQuery.facets ?? [], + loading, + error: error ?? null, + refetch: async () => { + const result = await refetch(); + return result.data?.listingQuery.facets ?? []; + }, + }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-merge-facet-values.ts b/admin/src/domains/discovery/facets/hooks/use-merge-facet-values.ts new file mode 100644 index 000000000..47139cdd8 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-merge-facet-values.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetValueMergeInput } from "@/graphql/types"; +import { FACET_GRID_QUERY, FACET_VALUE_MERGE_MUTATION } from "../graphql"; +import type { + FacetValueMergeMutationData, + FacetValueMergeMutationResult, + FacetValueMergeMutationVariables, +} from "../graphql/operation-types"; + +interface UseMergeFacetValuesReturn { + mergeFacetValues: ( + input: ApiFacetValueMergeInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useMergeFacetValues(): UseMergeFacetValuesReturn { + const [mergeFacetValuesMutation, { loading, error, reset }] = useMutation< + FacetValueMergeMutationData, + FacetValueMergeMutationVariables + >(FACET_VALUE_MERGE_MUTATION); + + const mergeFacetValues = useCallback( + async ( + input: ApiFacetValueMergeInput, + ): Promise => { + try { + const result = await mergeFacetValuesMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetValueMerge; + + return { + facetValue: payload?.facetValue ?? null, + sourceValues: payload?.sourceValues ?? [], + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + facetValue: null, + sourceValues: [], + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [mergeFacetValuesMutation], + ); + + return { mergeFacetValues, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-move-facet.ts b/admin/src/domains/discovery/facets/hooks/use-move-facet.ts new file mode 100644 index 000000000..1279563fb --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-move-facet.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetMoveInput } from "@/graphql/types"; +import { + FACET_DETAILS_QUERY, + FACET_GRID_QUERY, + FACET_MOVE_MUTATION, +} from "../graphql"; +import type { + FacetMoveMutationData, + FacetMoveMutationVariables, + FacetMutationResult, +} from "../graphql/operation-types"; + +interface UseMoveFacetReturn { + moveFacet: (input: ApiFacetMoveInput) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useMoveFacet(): UseMoveFacetReturn { + const [moveFacetMutation, { loading, error, reset }] = useMutation< + FacetMoveMutationData, + FacetMoveMutationVariables + >(FACET_MOVE_MUTATION); + + const moveFacet = useCallback( + async (input: ApiFacetMoveInput): Promise => { + try { + const result = await moveFacetMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY, FACET_DETAILS_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetMove; + + return { + facet: payload?.facet ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + facet: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [moveFacetMutation], + ); + + return { moveFacet, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-unmerge-facet-values.ts b/admin/src/domains/discovery/facets/hooks/use-unmerge-facet-values.ts new file mode 100644 index 000000000..0dd1367d4 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-unmerge-facet-values.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetValueUnmergeInput } from "@/graphql/types"; +import { FACET_GRID_QUERY, FACET_VALUE_UNMERGE_MUTATION } from "../graphql"; +import type { + FacetValueUnmergeMutationData, + FacetValueUnmergeMutationResult, + FacetValueUnmergeMutationVariables, +} from "../graphql/operation-types"; + +interface UseUnmergeFacetValuesReturn { + unmergeFacetValues: ( + input: ApiFacetValueUnmergeInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useUnmergeFacetValues(): UseUnmergeFacetValuesReturn { + const [unmergeFacetValuesMutation, { loading, error, reset }] = useMutation< + FacetValueUnmergeMutationData, + FacetValueUnmergeMutationVariables + >(FACET_VALUE_UNMERGE_MUTATION); + + const unmergeFacetValues = useCallback( + async ( + input: ApiFacetValueUnmergeInput, + ): Promise => { + try { + const result = await unmergeFacetValuesMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetValueUnmerge; + + return { + sourceValues: payload?.sourceValues ?? [], + affectedGroupValues: payload?.affectedGroupValues ?? [], + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + sourceValues: [], + affectedGroupValues: [], + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [unmergeFacetValuesMutation], + ); + + return { unmergeFacetValues, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-update-facet-scopes.ts b/admin/src/domains/discovery/facets/hooks/use-update-facet-scopes.ts new file mode 100644 index 000000000..8eb3d0d1a --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-update-facet-scopes.ts @@ -0,0 +1,74 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiFacetScopesUpdateInput, + ApiGenericUserError, +} from "@/graphql/types"; +import { + FACET_DETAILS_QUERY, + FACET_GRID_QUERY, + FACET_SCOPES_UPDATE_MUTATION, +} from "../graphql"; +import type { + FacetGridFields, + FacetScopesUpdateMutationData, + FacetScopesUpdateMutationVariables, +} from "../graphql/operation-types"; + +interface FacetScopesUpdateResult { + facets: FacetGridFields[]; + userErrors: ApiGenericUserError[]; +} + +interface UseUpdateFacetScopesReturn { + updateFacetScopes: ( + input: ApiFacetScopesUpdateInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useUpdateFacetScopes(): UseUpdateFacetScopesReturn { + const [mutation, { loading, error, reset }] = useMutation< + FacetScopesUpdateMutationData, + FacetScopesUpdateMutationVariables + >(FACET_SCOPES_UPDATE_MUTATION); + + const updateFacetScopes = useCallback( + async ( + input: ApiFacetScopesUpdateInput, + ): Promise => { + try { + const result = await mutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY, FACET_DETAILS_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetScopesUpdate; + + return { + facets: payload?.facets ?? [], + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + facets: [], + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [mutation], + ); + + return { + updateFacetScopes, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-update-facet-value.ts b/admin/src/domains/discovery/facets/hooks/use-update-facet-value.ts new file mode 100644 index 000000000..8636d9758 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-update-facet-value.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetValueUpdateInput } from "@/graphql/types"; +import { + FACET_GRID_QUERY, + FACET_VALUE_DETAILS_QUERY, + FACET_VALUE_UPDATE_MUTATION, +} from "../graphql"; +import type { + FacetValueMutationResult, + FacetValueUpdateMutationData, + FacetValueUpdateMutationVariables, +} from "../graphql/operation-types"; + +interface UseUpdateFacetValueReturn { + updateFacetValue: ( + input: ApiFacetValueUpdateInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useUpdateFacetValue(): UseUpdateFacetValueReturn { + const [updateFacetValueMutation, { loading, error, reset }] = useMutation< + FacetValueUpdateMutationData, + FacetValueUpdateMutationVariables + >(FACET_VALUE_UPDATE_MUTATION); + + const updateFacetValue = useCallback( + async ( + input: ApiFacetValueUpdateInput, + ): Promise => { + try { + const result = await updateFacetValueMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY, FACET_VALUE_DETAILS_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetValueUpdate; + + return { + facetValue: payload?.facetValue ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + facetValue: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [updateFacetValueMutation], + ); + + return { updateFacetValue, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-update-facet.ts b/admin/src/domains/discovery/facets/hooks/use-update-facet.ts new file mode 100644 index 000000000..e921a99a0 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-update-facet.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiFacetUpdateInput } from "@/graphql/types"; +import { + FACET_DETAILS_QUERY, + FACET_GRID_QUERY, + FACET_UPDATE_MUTATION, +} from "../graphql"; +import type { + FacetMutationResult, + FacetUpdateMutationData, + FacetUpdateMutationVariables, +} from "../graphql/operation-types"; + +interface UseUpdateFacetReturn { + updateFacet: (input: ApiFacetUpdateInput) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useUpdateFacet(): UseUpdateFacetReturn { + const [updateFacetMutation, { loading, error, reset }] = useMutation< + FacetUpdateMutationData, + FacetUpdateMutationVariables + >(FACET_UPDATE_MUTATION); + + const updateFacet = useCallback( + async (input: ApiFacetUpdateInput): Promise => { + try { + const result = await updateFacetMutation({ + variables: { input }, + refetchQueries: [FACET_GRID_QUERY, FACET_DETAILS_QUERY], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.facetUpdate; + + return { + facet: payload?.facet ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return { + facet: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [updateFacetMutation], + ); + + return { updateFacet, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/facets/hooks/use-upsert-facet-swatch.ts b/admin/src/domains/discovery/facets/hooks/use-upsert-facet-swatch.ts new file mode 100644 index 000000000..e55301b68 --- /dev/null +++ b/admin/src/domains/discovery/facets/hooks/use-upsert-facet-swatch.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiFacetSwatchCreateInput, + ApiFacetSwatchUpdateInput, +} from "@/graphql/types"; +import { + FACET_SWATCH_CREATE_MUTATION, + FACET_SWATCH_UPDATE_MUTATION, +} from "../graphql"; +import type { + FacetSwatchCreateMutationData, + FacetSwatchCreateMutationVariables, + FacetSwatchMutationResult, + FacetSwatchUpdateMutationData, + FacetSwatchUpdateMutationVariables, +} from "../graphql/operation-types"; + +interface UseUpsertFacetSwatchReturn { + createFacetSwatch: ( + input: ApiFacetSwatchCreateInput, + ) => Promise; + updateFacetSwatch: ( + input: ApiFacetSwatchUpdateInput, + ) => Promise; + loading: boolean; +} + +function getUnexpectedError(error: unknown): FacetSwatchMutationResult { + return { + facetSwatch: null, + userErrors: [ + { + message: + error instanceof Error ? error.message : "An unexpected error occurred", + code: "UNEXPECTED_ERROR", + }, + ], + }; +} + +export function useUpsertFacetSwatch(): UseUpsertFacetSwatchReturn { + const [createMutation, { loading: creating }] = useMutation< + FacetSwatchCreateMutationData, + FacetSwatchCreateMutationVariables + >(FACET_SWATCH_CREATE_MUTATION); + const [updateMutation, { loading: updating }] = useMutation< + FacetSwatchUpdateMutationData, + FacetSwatchUpdateMutationVariables + >(FACET_SWATCH_UPDATE_MUTATION); + + const createFacetSwatch = useCallback( + async ( + input: ApiFacetSwatchCreateInput, + ): Promise => { + try { + const result = await createMutation({ variables: { input } }); + const payload = result.data?.listingMutation.facetSwatchCreate; + + return { + facetSwatch: payload?.facetSwatch ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (error) { + return getUnexpectedError(error); + } + }, + [createMutation], + ); + + const updateFacetSwatch = useCallback( + async ( + input: ApiFacetSwatchUpdateInput, + ): Promise => { + try { + const result = await updateMutation({ variables: { input } }); + const payload = result.data?.listingMutation.facetSwatchUpdate; + + return { + facetSwatch: payload?.facetSwatch ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (error) { + return getUnexpectedError(error); + } + }, + [updateMutation], + ); + + return { + createFacetSwatch, + updateFacetSwatch, + loading: creating || updating, + }; +} diff --git a/admin/src/domains/discovery/facets/mappers/facet-errors.mapper.ts b/admin/src/domains/discovery/facets/mappers/facet-errors.mapper.ts new file mode 100644 index 000000000..da7ca425a --- /dev/null +++ b/admin/src/domains/discovery/facets/mappers/facet-errors.mapper.ts @@ -0,0 +1,45 @@ +import type { ApiGenericUserError } from "@/graphql/types"; + +export interface FacetFormError { + field: string | null; + message: string; +} + +const FIELD_ALIASES: Record = { + label: "label", + slug: "slug", + uiType: "uiType", + scopes: "scopes", + selectionMode: "selectionMode", + facetType: "facetType", + sources: "sources", + source: "source", + sourceHandles: "sourceHandles", + valueCandidates: "valueCandidates", + enabled: "enabled", + swatchId: "swatchId", +}; + +export function mapFacetUserErrorsToFormErrors( + errors: ApiGenericUserError[], +): FacetFormError[] { + return errors.map((error) => { + const fieldPath = error.field ?? []; + const field = fieldPath.includes("sources") + ? "sources" + : fieldPath.includes("valueCandidates") + ? "valueCandidates" + : fieldPath.at(-1) ?? null; + return { + field: field ? FIELD_ALIASES[field] ?? field : null, + message: error.message, + }; + }); +} + +export function getFirstUserErrorMessage( + errors: ApiGenericUserError[], + fallback: string, +): string { + return errors[0]?.message ?? fallback; +} diff --git a/admin/src/domains/discovery/facets/mappers/facet-grid-row.mapper.ts b/admin/src/domains/discovery/facets/mappers/facet-grid-row.mapper.ts new file mode 100644 index 000000000..a48c9bc59 --- /dev/null +++ b/admin/src/domains/discovery/facets/mappers/facet-grid-row.mapper.ts @@ -0,0 +1,118 @@ +import type { ApiFacetSwatch } from "@/graphql/types"; +import { FacetType } from "@/graphql/types"; +import type { + FacetGridFields, + FacetValueGridFields, +} from "../graphql/operation-types"; + +export type FacetGridRowId = `facet:${string}`; + +export interface FacetGridValue { + id: string; + apiId: string; + sortIndex: number; + name: string; + slug?: string; + enabled?: boolean; + sourceHandles: string[]; + linkedSourceHandlesCount: number; + swatchId?: string | null; + swatch?: ApiFacetSwatch | null; +} + +export interface FacetGridRow { + id: FacetGridRowId; + apiId: string; + sortIndex: number; + name: string; + slug?: string; + facetType?: FacetGridFields["facetType"]; + uiType?: FacetGridFields["uiType"]; + selectionMode?: FacetGridFields["selectionMode"]; + scopes: FacetGridFields["scopes"]; + lexoRank?: string; + valuesCount?: number; + enabledValuesCount?: number; + linkedSourceHandlesCount?: number; + values: FacetGridValue[]; +} + +export function toFacetRowId(apiId: string): FacetGridRowId { + return `facet:${apiId}`; +} + +export function isDiscreteFacetType(type: FacetType | undefined): boolean { + return ( + type === FacetType.Tag || + type === FacetType.Feature || + type === FacetType.Option + ); +} + +function getFacetRow(facet: FacetGridFields, sortIndex: number): FacetGridRow { + const facetRowId = toFacetRowId(facet.id); + const shouldRenderValues = isDiscreteFacetType(facet.facetType); + const sortedValues = shouldRenderValues + ? [...facet.values].sort((left, right) => left.sortIndex - right.sortIndex) + : []; + const values = sortedValues.map(mapFacetValueToGridValue); + const linkedSourceHandlesCount = sortedValues.reduce( + (count, value) => count + getFacetValueSourceHandles(value).length, + 0, + ); + + return { + id: facetRowId, + apiId: facet.id, + sortIndex, + lexoRank: facet.lexoRank, + name: facet.label, + slug: facet.slug, + facetType: facet.facetType, + uiType: facet.uiType, + selectionMode: facet.selectionMode, + scopes: facet.scopes, + valuesCount: sortedValues.length, + enabledValuesCount: sortedValues.filter((value) => value.enabled).length, + linkedSourceHandlesCount, + values, + }; +} + +function getFacetValueSourceHandles(value: FacetValueGridFields): string[] { + return value.sourceValues.map((sourceValue) => sourceValue.handle); +} + +function mapFacetValueToGridValue( + value: FacetValueGridFields, +): FacetGridValue { + const sourceHandles = getFacetValueSourceHandles(value); + + return { + id: value.id, + apiId: value.id, + sortIndex: value.sortIndex, + name: value.label, + slug: value.handle, + enabled: value.enabled, + sourceHandles, + linkedSourceHandlesCount: sourceHandles.length, + swatchId: value.swatch?.id ?? null, + swatch: value.swatch as ApiFacetSwatch | null, + }; +} + +export function apiFacetsToFacetGridRows( + facets: FacetGridFields[], +): FacetGridRow[] { + return [...facets] + .sort((left, right) => { + const rank = left.lexoRank.localeCompare(right.lexoRank); + return rank === 0 ? left.id.localeCompare(right.id) : rank; + }) + .map((facet, index) => getFacetRow(facet, index)); +} + +export function getMaxRootSortIndex(rows: FacetGridRow[]): number { + return Math.max(-1, ...rows.map((row) => row.sortIndex)); +} diff --git a/admin/src/domains/discovery/facets/mappers/facet-input.mapper.ts b/admin/src/domains/discovery/facets/mappers/facet-input.mapper.ts new file mode 100644 index 000000000..d72d4f021 --- /dev/null +++ b/admin/src/domains/discovery/facets/mappers/facet-input.mapper.ts @@ -0,0 +1,96 @@ +import { slugify } from "transliteration/dist/node/src/node/index.js"; +import type { + ApiFacetCreateInput, + ApiFacetUpdateInput, + FacetScopeType, +} from "@/graphql/types"; +import { + FacetSelectionMode, + FacetType, + FacetUiType, +} from "@/graphql/types"; + +export interface FacetFormInput { + label: string; + slug: string; + facetType: FacetType; + uiType: FacetUiType; + scopes: FacetScopeType[]; + sources?: Array<{ + handle: string; + name: string; + }>; + source?: { + handle: string; + name: string; + } | null; + selectedValueCandidates?: Array<{ + handle: string; + label: string; + sourceHandle: string; + }>; +} + +export function getAllowedFacetUiTypes(facetType: FacetType): FacetUiType[] { + if (facetType === FacetType.Price) { + return [FacetUiType.Range]; + } + if (facetType === FacetType.InStock) { + return [FacetUiType.Boolean]; + } + return [FacetUiType.Checkbox, FacetUiType.Radio, FacetUiType.Dropdown]; +} + +export function getDefaultFacetUiType(facetType: FacetType): FacetUiType { + return getAllowedFacetUiTypes(facetType)[0]; +} + +export function getDefaultFacetSelectionMode( + uiType: FacetUiType, +): FacetSelectionMode { + return uiType === FacetUiType.Checkbox + ? FacetSelectionMode.Multi + : FacetSelectionMode.Single; +} + +export function normalizeFacetSlug(value: string): string { + return slugify(value.trim()); +} + +export function mapFacetFormToCreateInput( + values: FacetFormInput, + _sortIndex?: number, +): ApiFacetCreateInput { + const sources = values.sources ?? (values.source ? [values.source] : []); + + return { + label: values.label.trim(), + slug: normalizeFacetSlug(values.slug), + facetType: values.facetType, + uiType: values.uiType, + scopes: values.scopes, + selectionMode: getDefaultFacetSelectionMode(values.uiType), + sources: sources.map((source) => ({ + handle: source.handle, + name: source.name, + })), + valueCandidates: (values.selectedValueCandidates ?? []).map((candidate) => ({ + handle: candidate.handle, + label: candidate.label, + sourceHandle: candidate.sourceHandle, + })), + }; +} + +export function mapFacetFormToUpdateInput( + id: string, + values: Omit, +): ApiFacetUpdateInput { + return { + id, + label: values.label.trim(), + slug: normalizeFacetSlug(values.slug), + uiType: values.uiType, + scopes: values.scopes, + }; +} diff --git a/admin/src/domains/discovery/facets/mappers/facet-ui.mapper.tsx b/admin/src/domains/discovery/facets/mappers/facet-ui.mapper.tsx new file mode 100644 index 000000000..a83ce15c4 --- /dev/null +++ b/admin/src/domains/discovery/facets/mappers/facet-ui.mapper.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from "react"; +import { LuCircleCheck as CheckCircleOutlined, LuSquareCheckBig as CheckSquareOutlined, LuMenu as MenuOutlined, LuSlidersHorizontal as SlidersOutlined, LuList as UnorderedListOutlined } from "react-icons/lu"; +import { + LuDollarSign, + LuPackageCheck, + LuSlidersHorizontal, + LuSparkles, + LuTag, +} from "react-icons/lu"; +import { FacetScopeType, FacetType, FacetUiType } from "@/graphql/types"; + +interface FacetTypeUiMapping { + label: string; + shortLabel: string; + sourceTypeLabel: string; + icon: ReactNode; +} + +interface FacetUiTypeMapping { + label: string; + icon: ReactNode; +} + +interface FacetScopeUiMapping { + label: string; + description: string; +} + +export const FACET_UI_MAPPINGS: { + facetTypes: Record; + uiTypes: Record; + scopes: Record; +} = { + facetTypes: { + [FacetType.Price]: { + label: "Price", + shortLabel: "Price", + sourceTypeLabel: "Standard", + icon: , + }, + [FacetType.InStock]: { + label: "Availability", + shortLabel: "Availability", + sourceTypeLabel: "Standard", + icon: , + }, + [FacetType.Tag]: { + label: "Product Tags", + shortLabel: "Tags", + sourceTypeLabel: "Standard", + icon: , + }, + [FacetType.Option]: { + label: "Product Options", + shortLabel: "Options", + sourceTypeLabel: "Product Option", + icon: , + }, + [FacetType.Feature]: { + label: "Product Features", + shortLabel: "Features", + sourceTypeLabel: "Product Feature", + icon: , + }, + }, + uiTypes: { + [FacetUiType.Checkbox]: { + label: "Checkbox", + icon: , + }, + [FacetUiType.Radio]: { + label: "Radio", + icon: , + }, + [FacetUiType.Dropdown]: { + label: "Dropdown", + icon: , + }, + [FacetUiType.Range]: { + label: "Range", + icon: , + }, + [FacetUiType.Boolean]: { + label: "Boolean", + icon: , + }, + }, + scopes: { + [FacetScopeType.Search]: { + label: "Search", + description: "Listings without a category context", + }, + [FacetScopeType.Category]: { + label: "Categories", + description: "All category-scoped listings", + }, + }, +}; + +export function getFacetSourceTypeLabel(facetType: FacetType): string { + return FACET_UI_MAPPINGS.facetTypes[facetType].sourceTypeLabel; +} + +export function getFacetTypeIcon(facetType: FacetType): ReactNode { + return FACET_UI_MAPPINGS.facetTypes[facetType].icon; +} + +export function getFacetSourceHandleLabel( + facetType: FacetType, + handle: string, +): string | null { + void handle; + return FACET_UI_MAPPINGS.facetTypes[facetType].label ?? null; +} + +export function getFacetUiTypeOptions( + uiTypes: FacetUiType[], +): Array<{ key: FacetUiType; label: string; icon: ReactNode }> { + return uiTypes.map((uiType) => ({ + key: uiType, + ...FACET_UI_MAPPINGS.uiTypes[uiType], + })); +} + +export function getFacetScopeLabel(scope: FacetScopeType): string { + return FACET_UI_MAPPINGS.scopes[scope].label; +} diff --git a/admin/src/domains/discovery/facets/mappers/facet-value-input.mapper.ts b/admin/src/domains/discovery/facets/mappers/facet-value-input.mapper.ts new file mode 100644 index 000000000..f5b255263 --- /dev/null +++ b/admin/src/domains/discovery/facets/mappers/facet-value-input.mapper.ts @@ -0,0 +1,89 @@ +import { slugify } from "transliteration/dist/node/src/node/index.js"; +import type { + ApiFacetValueCreateInput, + ApiFacetValueUpdateInput, +} from "@/graphql/types"; + +export interface FacetValueFormInput { + label: string; + slug: string; + enabled: boolean; + sourceHandles: string[]; + swatchId?: string | null; +} + +export function normalizeSourceHandles(sourceHandles: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const rawHandle of sourceHandles) { + const handle = rawHandle.trim(); + if (!handle || seen.has(handle)) { + continue; + } + seen.add(handle); + result.push(handle); + } + + return result; +} + +export function getDuplicateSourceHandles(sourceHandles: string[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + + for (const rawHandle of sourceHandles) { + const handle = rawHandle.trim(); + if (!handle) { + continue; + } + if (seen.has(handle)) { + duplicates.add(handle); + continue; + } + seen.add(handle); + } + + return [...duplicates]; +} + +export function normalizeFacetValueSlug(value: string): string { + return slugify(value.trim()); +} + +export function mapFacetValueFormToCreateInput( + facetId: string, + values: FacetValueFormInput, + sortIndex?: number, +): ApiFacetValueCreateInput { + return { + facetId, + label: values.label.trim(), + handle: normalizeFacetValueSlug(values.slug), + enabled: values.enabled, + swatchId: values.swatchId ?? null, + sortIndex, + }; +} + +export function mapFacetValueFormToUpdateInput( + id: string, + values: FacetValueFormInput, +): ApiFacetValueUpdateInput { + return { + id, + label: values.label.trim(), + handle: normalizeFacetValueSlug(values.slug), + enabled: values.enabled, + swatchId: values.swatchId ?? null, + }; +} + +export function mapSourceHandlesToFacetValueUpdateInput( + id: string, + _sourceHandles: string[], +): ApiFacetValueUpdateInput { + return { + id, + }; +} diff --git a/admin/src/domains/discovery/facets/mappers/index.ts b/admin/src/domains/discovery/facets/mappers/index.ts new file mode 100644 index 000000000..d0e1f3902 --- /dev/null +++ b/admin/src/domains/discovery/facets/mappers/index.ts @@ -0,0 +1,5 @@ +export * from "./facet-grid-row.mapper"; +export * from "./facet-input.mapper"; +export * from "./facet-value-input.mapper"; +export * from "./facet-errors.mapper"; +export * from "./facet-ui.mapper"; diff --git a/admin/src/domains/discovery/facets/modals.ts b/admin/src/domains/discovery/facets/modals.ts new file mode 100644 index 000000000..a447c3114 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals.ts @@ -0,0 +1,126 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; +import type { FacetScopeType, FacetType, FacetUiType } from "@/graphql/types"; +import type { FacetValueEditorRow } from "./modals/edit-facet-modal/types"; +import type { FacetSourcePickerEntity } from "./pickers/facet-source-picker-config"; + +export const FACET_CREATE_MODAL_TYPE = "facet-create"; +export const FACET_EDIT_MODAL_TYPE = "facet-edit"; +export const FACET_SOURCE_PICKER_MODAL_TYPE = "facet-source-picker"; +export const FACET_SCOPE_PICKER_MODAL_TYPE = "facet-scope-picker"; +export const FACET_VALUE_LINK_SOURCES_MODAL_TYPE = "facet-value-link-sources"; +export const FACET_VALUES_MERGE_MODAL_TYPE = "facet-values-merge"; +export const FACET_VALUE_GROUP_MODAL_TYPE = "facet-value-group"; +export const FACET_VALUE_CANDIDATES_MODAL_TYPE = "facet-value-candidates"; +export const FACET_SWATCH_EDIT_MODAL_TYPE = "facet-swatch-edit"; + +export interface ICreateFacetModalPayload extends IModalStackPayload { + nextSortIndex?: number; + initialValues?: Partial<{ + label: string; + slug: string; + facetType: FacetType; + uiType: FacetUiType; + scopes: FacetScopeType[]; + sources: Array<{ + handle: string; + name: string; + }>; + source: { + handle: string; + name: string; + } | null; + }>; + onSaved?: () => Promise | unknown; +} + +export interface IEditFacetModalPayload extends IModalStackPayload { + facetId: string; + onSaved?: () => Promise | unknown; +} + +export interface IFacetSourcePickerModalPayload extends IModalStackPayload { + selectionMode?: "single" | "multi"; + initialSelection?: string[]; + excludeIds?: string[]; + maxSelection?: number; + queryMeta?: unknown; + initialFacetType?: FacetType; + onConfirm: (entities: FacetSourcePickerEntity[], ids: string[]) => void; +} + +export interface IFacetScopePickerModalPayload extends IModalStackPayload { + scope: FacetScopeType; + facets: Array<{ + id: string; + scopes: FacetScopeType[]; + }>; + onSaved?: () => Promise | unknown; +} + +export interface ILinkSourceValuesModalPayload extends IModalStackPayload { + valueId: string; + valueLabel: string; + sourceHandles: string[]; + onSave?: (sourceHandles: string[]) => Promise | boolean | void; +} + +export interface IFacetValuesMergeModalPayload extends IModalStackPayload { + selectedRowIds: string[]; +} + +export interface IFacetValueGroupModalPayload extends IModalStackPayload { + groupMode: "create" | "edit" | "add-to-existing"; + facetId: string; + selectedValues: FacetValueEditorRow[]; + availableValues: FacetValueEditorRow[]; + groupValueId?: string; + initialGroupLabel?: string; + initialGroupedValues?: FacetValueEditorRow[]; + onSaved?: () => Promise | unknown; +} + +export interface IFacetValueCandidatesModalPayload extends IModalStackPayload { + facetId: string; + facetType: FacetType; + sourceHandles?: string[]; + nextSortIndex?: number; + onSaved?: () => Promise | unknown; +} + +export interface IFacetSwatchEditModalPayload extends IModalStackPayload { + valueId: string; +} + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [FACET_CREATE_MODAL_TYPE]: ICreateFacetModalPayload; + [FACET_EDIT_MODAL_TYPE]: IEditFacetModalPayload; + [FACET_SOURCE_PICKER_MODAL_TYPE]: IFacetSourcePickerModalPayload; + [FACET_SCOPE_PICKER_MODAL_TYPE]: IFacetScopePickerModalPayload; + [FACET_VALUE_LINK_SOURCES_MODAL_TYPE]: ILinkSourceValuesModalPayload; + [FACET_VALUES_MERGE_MODAL_TYPE]: IFacetValuesMergeModalPayload; + [FACET_VALUE_GROUP_MODAL_TYPE]: IFacetValueGroupModalPayload; + [FACET_VALUE_CANDIDATES_MODAL_TYPE]: IFacetValueCandidatesModalPayload; + [FACET_SWATCH_EDIT_MODAL_TYPE]: IFacetSwatchEditModalPayload; + } +} + +export const useCreateFacetModal = + createModalStackHook(FACET_CREATE_MODAL_TYPE); +export const useEditFacetModal = createModalStackHook(FACET_EDIT_MODAL_TYPE); +export const useFacetSourcePickerModal = createModalStackHook( + FACET_SOURCE_PICKER_MODAL_TYPE, +); +export const useFacetScopePickerModal = createModalStackHook( + FACET_SCOPE_PICKER_MODAL_TYPE, +); +export const useLinkSourceValuesModal = createModalStackHook( + FACET_VALUE_LINK_SOURCES_MODAL_TYPE, +); +export const useFacetValueGroupModal = createModalStackHook( + FACET_VALUE_GROUP_MODAL_TYPE, +); +export const useFacetValueCandidatesModal = createModalStackHook( + FACET_VALUE_CANDIDATES_MODAL_TYPE, +); diff --git a/admin/src/domains/discovery/facets/modals/components/facet-scope-selector.tsx b/admin/src/domains/discovery/facets/modals/components/facet-scope-selector.tsx new file mode 100644 index 000000000..6a8efeba8 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/components/facet-scope-selector.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Checkbox, Flex } from "antd"; +import { createStyles } from "antd-style"; +import { FacetScopeType } from "@/graphql/types"; +import { FACET_UI_MAPPINGS } from "../../mappers"; + +const useStyles = createStyles(({ token }) => ({ + options: { + width: "100%", + }, + option: { + flex: 1, + minWidth: 0, + padding: 12, + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadius, + }, + optionContent: { + display: "inline-flex", + flexDirection: "column", + gap: 2, + verticalAlign: "top", + }, + optionLabel: { + color: token.colorText, + fontWeight: 500, + }, + optionDescription: { + color: token.colorTextSecondary, + fontSize: 12, + lineHeight: 1.4, + }, +})); + +const SCOPE_OPTIONS = [ + { + value: FacetScopeType.Search, + ...FACET_UI_MAPPINGS.scopes[FacetScopeType.Search], + }, + { + value: FacetScopeType.Category, + ...FACET_UI_MAPPINGS.scopes[FacetScopeType.Category], + }, +] as const; + +interface FacetScopeSelectorProps { + value: FacetScopeType[]; + onChange: (value: FacetScopeType[]) => void; +} + +export function FacetScopeSelector({ + value, + onChange, +}: FacetScopeSelectorProps) { + const { styles } = useStyles(); + + return ( + onChange(nextValue as FacetScopeType[])} + > + + {SCOPE_OPTIONS.map((option) => ( + + + {option.label} + + {option.description} + + + + ))} + + + ); +} diff --git a/admin/src/domains/discovery/facets/modals/components/facet-ui-type-selector.tsx b/admin/src/domains/discovery/facets/modals/components/facet-ui-type-selector.tsx new file mode 100644 index 000000000..ccffdd729 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/components/facet-ui-type-selector.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useMemo } from "react"; +import { Button, Dropdown, Flex } from "antd"; +import { FacetUiType } from "@/graphql/types"; +import { getFacetUiTypeOptions } from "../../mappers"; + +interface FacetUiTypeSelectorProps { + value: FacetUiType; + options: FacetUiType[]; + onChange: (uiType: FacetUiType) => void; + disabled?: boolean; +} + +export function FacetUiTypeSelector({ + value, + options, + onChange, + disabled = false, +}: FacetUiTypeSelectorProps) { + const facetUiTypeOptions = useMemo( + () => getFacetUiTypeOptions(options), + [options], + ); + const current = getFacetUiTypeOptions([value])[0]; + const menuItems = facetUiTypeOptions.map((option) => ({ + key: option.key, + label: ( + + {option.icon} + {option.label} + + ), + onClick: () => onChange(option.key), + })); + + return ( + + + + ); +} diff --git a/admin/src/domains/discovery/facets/modals/create-facet-modal/create-facet-modal.tsx b/admin/src/domains/discovery/facets/modals/create-facet-modal/create-facet-modal.tsx new file mode 100644 index 000000000..6a3384925 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/create-facet-modal/create-facet-modal.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { useCallback, useEffect, useMemo } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { App, Button, Flex, Input } from "antd"; +import { createStyles } from "antd-style"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { LuSwatchBook } from "react-icons/lu"; +import { slugify } from "transliteration/dist/node/src/node/index.js"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + getAllowedFacetUiTypes, + getDefaultFacetUiType, + getFacetSourceTypeLabel, + getFacetTypeIcon, + isDiscreteFacetType, + mapFacetFormToCreateInput, + mapFacetUserErrorsToFormErrors, +} from "../../mappers"; +import { useCreateFacet } from "../../hooks"; +import { + useFacetSourcePickerModal, + type ICreateFacetModalPayload, +} from "../../modals"; +import { FacetUiTypeSelector } from "../components/facet-ui-type-selector"; +import { FacetScopeSelector } from "../components/facet-scope-selector"; +import { + createFacetSchema, + type CreateFacetFormInput, + type CreateFacetFormValues, +} from "./schema"; +import { FacetScopeType, FacetType } from "@/graphql/types"; +import type { FacetSourcePickerEntity } from "../../pickers/facet-source-picker-config"; +import { FacetValueCandidatesGrid } from "./facet-value-candidates-grid"; + +const useStyles = createStyles(({ token }) => ({ + fieldGroup: { + display: "flex", + gap: 16, + marginBottom: 16, + }, + field: { + flex: 1, + minWidth: 0, + }, + label: { + display: "flex", + alignItems: "center", + gap: 4, + marginBottom: 4, + fontSize: 13, + fontWeight: 500, + color: token.colorText, + }, + error: { + fontSize: 12, + color: token.colorError, + marginTop: 4, + }, + stackedField: { + marginBottom: 16, + }, + sourceSelectorButton: { + width: "100%", + height: 46, + justifyContent: "flex-start", + paddingInline: 12, + borderColor: token.colorBorder, + color: token.colorText, + }, + sourceSelectorContent: { + justifyContent: "flex-start", + width: "100%", + minWidth: 0, + }, + sourceLine: { + display: "flex", + alignItems: "center", + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + sourceValue: { + display: "inline-flex", + alignItems: "center", + gap: 4, + marginLeft: 8, + minWidth: 0, + verticalAlign: "middle", + }, +})); + +const MULTI_SOURCE_FACET_TYPES = new Set([ + FacetType.Option, + FacetType.Feature, +]); + +interface FacetSourceSelectorProps { + value: CreateFacetFormInput["sources"]; + facetType: FacetType; + hasError?: boolean; + onClick: () => void; +} + +function FacetSourceSelector({ + value, + facetType, + hasError = false, + onClick, +}: FacetSourceSelectorProps) { + const { styles } = useStyles(); + const sources = value ?? []; + const hasSources = sources.length > 0; + const sourceTypeLabel = hasSources ? getFacetSourceTypeLabel(facetType) : "Source"; + const sourceLabel = hasSources + ? sources.map((source) => source.name).join(", ") + : "Select source"; + const icon = hasSources ? getFacetTypeIcon(facetType) : null; + + return ( + + ); +} + +const DEFAULT_VALUES: CreateFacetFormInput = { + label: "", + slug: "", + facetType: FacetType.Option, + uiType: getDefaultFacetUiType(FacetType.Option), + scopes: [FacetScopeType.Search, FacetScopeType.Category], + sources: [], + selectedValueCandidates: [], +}; + +export function CreateFacetModal() { + const { styles } = useStyles(); + const { message } = App.useApp(); + const { payload, pop } = useModalStackContext(); + const typedPayload = payload as ICreateFacetModalPayload; + const { createFacet, loading } = useCreateFacet(); + const initialSources = + typedPayload.initialValues?.sources ?? + (typedPayload.initialValues?.source + ? [typedPayload.initialValues.source] + : undefined); + + const methods = useForm({ + resolver: zodResolver(createFacetSchema), + defaultValues: { + ...DEFAULT_VALUES, + ...typedPayload.initialValues, + ...(initialSources ? { sources: initialSources } : {}), + }, + }); + const { clearErrors, control, handleSubmit, setError, setValue, watch } = + methods; + const label = watch("label"); + const facetType = watch("facetType"); + const uiType = watch("uiType"); + const sources = watch("sources"); + const discrete = isDiscreteFacetType(facetType); + const uiTypeOptions = useMemo( + () => getAllowedFacetUiTypes(facetType), + [facetType], + ); + const sourceHandles = useMemo( + () => (sources ?? []).map((source) => source.handle), + [sources], + ); + const initialSourceSelection = useMemo( + () => sourceHandles.map((handle) => `${facetType}:${handle}`), + [facetType, sourceHandles], + ); + + const { push: openFacetSourcePicker } = useFacetSourcePickerModal(); + const openPicker = useCallback(() => { + openFacetSourcePicker({ + selectionMode: "multi", + initialSelection: initialSourceSelection, + initialFacetType: sources?.length ? facetType : undefined, + queryMeta: { + allowedFacetTypes: [ + FacetType.Price, + FacetType.Tag, + FacetType.Option, + FacetType.Feature, + FacetType.InStock, + ], + }, + onConfirm: ( + selectedSourceEntities: FacetSourcePickerEntity[], + selectedIds: string[], + ) => { + const selectedSourceById = new Map( + selectedSourceEntities.map((source) => [source.id, source]), + ); + for (const source of sources ?? []) { + const id = `${facetType}:${source.handle}`; + if (!selectedIds.includes(id) || selectedSourceById.has(id)) continue; + selectedSourceById.set(id, { + id, + title: source.name, + facetType, + handle: source.handle, + name: source.name, + typeLabel: getFacetSourceTypeLabel(facetType), + }); + } + const selectedSources = selectedIds + .map((id) => selectedSourceById.get(id)) + .filter((source): source is FacetSourcePickerEntity => + Boolean(source), + ); + + const firstSelectedSource = selectedSources[0]; + if (!firstSelectedSource) return; + const selectedFacetType = firstSelectedSource.facetType; + const hasMixedFacetTypes = selectedSources.some( + (source) => source.facetType !== selectedFacetType, + ); + + if (hasMixedFacetTypes) { + setError("sources", { + message: "Select sources from one filter type", + }); + message.error("Select sources from one filter type."); + return; + } + + if ( + selectedSources.length > 1 && + !MULTI_SOURCE_FACET_TYPES.has(selectedFacetType) + ) { + setError("sources", { + message: "Only option and feature filters can use multiple sources", + }); + message.error( + "Only option and feature filters can use multiple sources.", + ); + return; + } + + setValue("facetType", selectedFacetType, { + shouldValidate: true, + shouldDirty: true, + }); + setValue( + "sources", + selectedSources.map((source) => ({ + handle: source.handle, + name: source.name, + })), + { shouldValidate: true, shouldDirty: true }, + ); + clearErrors("sources"); + setValue("selectedValueCandidates", [], { + shouldValidate: true, + shouldDirty: true, + }); + + const allowed = getAllowedFacetUiTypes(selectedFacetType); + if (!allowed.includes(uiType)) { + setValue("uiType", getDefaultFacetUiType(selectedFacetType), { + shouldValidate: true, + shouldDirty: true, + }); + } + }, + }); + }, [ + clearErrors, + facetType, + initialSourceSelection, + message, + openFacetSourcePicker, + setError, + setValue, + sources, + uiType, + ]); + + useEffect(() => { + setValue("slug", slugify(label), { shouldValidate: Boolean(label) }); + }, [label, setValue]); + + useEffect(() => { + if (!uiTypeOptions.includes(uiType)) { + const nextUiType = getDefaultFacetUiType(facetType); + setValue("uiType", nextUiType, { shouldValidate: true }); + } + }, [facetType, setValue, uiType, uiTypeOptions]); + + const onSubmit = useCallback( + async (values: CreateFacetFormValues) => { + const result = await createFacet( + mapFacetFormToCreateInput( + { ...values, slug: slugify(values.label) }, + typedPayload.nextSortIndex, + ), + ); + + if (result.userErrors.length > 0) { + mapFacetUserErrorsToFormErrors(result.userErrors).forEach((error) => { + if (error.field === "label") { + setError("label", { message: error.message }); + } + if (error.field === "slug") { + setError("slug", { message: error.message }); + } + if (error.field === "uiType") { + setError("uiType", { message: error.message }); + } + if (error.field === "scopes") { + setError("scopes", { message: error.message }); + } + if ( + error.field === "facetType" || + error.field === "source" || + error.field === "sources" + ) { + setError("sources", { message: error.message }); + } + if (error.field === "valueCandidates") { + setError("selectedValueCandidates", { message: error.message }); + } + }); + message.error(result.userErrors[0].message); + return; + } + + message.success("Filter created."); + await typedPayload.onSaved?.(); + pop(); + }, + [createFacet, message, pop, setError, typedPayload], + ); + + return ( + + + } + > + + +
    +
    Source
    + ( + <> + + {sourcesError ? ( +
    {sourcesError.message}
    + ) : null} + + )} + /> +
    +
    +
    +
    Label
    + ( + <> + + {error &&
    {error.message}
    } + + )} + /> +
    +
    +
    +
    Available in
    + ( + <> + + {error ? ( +
    {error.message}
    + ) : null} + + )} + /> +
    +
    + + {discrete ? ( + + + ( + + )} + /> + + + + + + + +
    + ) : ( + } + placeholder="Search filter values" + value={search} + onChange={(event) => setSearch(event.target.value)} + data-testid="facet-values-search-input" + /> + )} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={filteredValues} + columnDefs={columnDefs} + getRowId={(params: GetRowIdParams) => params.data.id} + rowHeight={44} + headerHeight={38} + rowDragManaged + rowSelection={rowSelection} + selectionColumnDef={{ + cellStyle: { display: "flex", alignItems: "center" }, + }} + suppressCellFocus + suppressMovableColumns + animateRows + noRowsOverlayComponent={() => ( + + No values found + + )} + defaultColDef={{ + sortable: false, + resizable: false, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }} + onSelectionChanged={handleSelectionChanged} + onRowDragEnd={handleDragEnd} + /> +
    +
    + ); +} diff --git a/admin/src/domains/discovery/facets/modals/edit-facet-modal/components/facet-values-list.tsx b/admin/src/domains/discovery/facets/modals/edit-facet-modal/components/facet-values-list.tsx new file mode 100644 index 000000000..8a27f3b45 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/edit-facet-modal/components/facet-values-list.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Input, Typography } from "antd"; +import { LuGripVertical as HolderOutlined } from "react-icons/lu"; +import { + closestCenter, + DndContext, + DragEndEvent, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { OptionDisplayType } from "@/graphql/types"; +import type { + OptionEditorSwatch, + OptionEditorValue, +} from "@/domains/inventory/products/modals/edit-options-modal/types"; +import { useStyles as useOptionValueStyles } from "@/domains/inventory/products/modals/edit-options-modal/edit-options-modal.styles"; +import { SortableValue } from "@/domains/inventory/products/modals/edit-options-modal/components/sortable-value"; + +interface FacetValuesListProps { + values: OptionEditorValue[]; + swatchesEnabled: boolean; + onReorder: (values: OptionEditorValue[]) => void; + onUpdateValueName: (valueIndex: number, name: string) => void; + onUpdateValueSwatch: ( + valueIndex: number, + swatch: OptionEditorSwatch, + ) => void; + onDeleteValue: (valueIndex: number) => void; +} + +export function FacetValuesList({ + values, + swatchesEnabled, + onReorder, + onUpdateValueName, + onUpdateValueSwatch, + onDeleteValue, +}: FacetValuesListProps) { + const { styles } = useOptionValueStyles(); + const [activeValueId, setActiveValueId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const activeValue = useMemo( + () => values.find((value) => value.id === activeValueId) ?? null, + [activeValueId, values], + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = values.findIndex((value) => value.id === active.id); + const newIndex = values.findIndex((value) => value.id === over.id); + + if (oldIndex >= 0 && newIndex >= 0) { + onReorder(arrayMove(values, oldIndex, newIndex)); + } + } + setActiveValueId(null); + }; + + if (values.length === 0) { + return ( + + No public values yet. + + ); + } + + return ( + setActiveValueId(event.active.id as string)} + onDragEnd={handleDragEnd} + > + value.id)} + strategy={verticalListSortingStrategy} + > +
    + {values.map((value, valueIndex) => ( + onUpdateValueName(valueIndex, name)} + onSwatchChange={(swatch) => + onUpdateValueSwatch(valueIndex, swatch) + } + onDelete={() => onDeleteValue(valueIndex)} + /> + ))} +
    +
    + + + {activeValue && ( +
    + + + + } + /> +
    + )} +
    +
    + ); +} diff --git a/admin/src/domains/discovery/facets/modals/edit-facet-modal/edit-facet-modal.tsx b/admin/src/domains/discovery/facets/modals/edit-facet-modal/edit-facet-modal.tsx new file mode 100644 index 000000000..07c87d2d7 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/edit-facet-modal/edit-facet-modal.tsx @@ -0,0 +1,866 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Alert, + App, + Button, + Dropdown, + Flex, + Input, + Skeleton, + Typography, +} from "antd"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { createStyles } from "antd-style"; +import { LuSwatchBook } from "react-icons/lu"; +import { slugify } from "transliteration/dist/node/src/node/index.js"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + getAllowedFacetUiTypes, + getFacetSourceHandleLabel, + getFacetSourceTypeLabel, + getFacetTypeIcon, + isDiscreteFacetType, + mapFacetFormToUpdateInput, + mapFacetUserErrorsToFormErrors, +} from "../../mappers"; +import { + useCreateFacetValue, + useDeleteFacetValue, + useFacet, + useUnmergeFacetValues, + useUpsertFacetSwatch, + useUpdateFacet, + useUpdateFacetValue, +} from "../../hooks"; +import { + type IEditFacetModalPayload, + useFacetValueCandidatesModal, + useFacetValueGroupModal, +} from "../../modals"; +import type { + FacetSwatchFields, + FacetValueGridFields, + FacetGridFields, +} from "../../graphql/operation-types"; +import { DEFAULT_SWATCH } from "@/domains/inventory/products/modals/edit-options-modal/edit-options-modal.constants"; +import type { OptionEditorSwatch } from "@/domains/inventory/products/modals/edit-options-modal/types"; +import { + editFacetSchema, + type EditFacetFormInput, + type EditFacetFormValues, +} from "./schema"; +import { FacetUiTypeSelector } from "../components/facet-ui-type-selector"; +import { FacetScopeSelector } from "../components/facet-scope-selector"; +import { FacetValuesGrid } from "./components/facet-values-grid"; +import type { FacetValueEditorRow } from "./types"; +import { + FacetValueKind, + FacetScopeType, + FacetType, + FacetUiType, + SwatchType, + type ApiFacetSwatchCreateInput, + type ApiFacetSwatchUpdateInput, +} from "@/graphql/types"; + +const useStyles = createStyles(({ token }) => ({ + fieldGroup: { + display: "flex", + gap: 16, + marginBottom: 16, + }, + field: { + flex: 1, + minWidth: 0, + }, + label: { + marginBottom: 4, + fontSize: 13, + fontWeight: 500, + color: token.colorText, + }, + error: { + fontSize: 12, + color: token.colorError, + marginTop: 4, + }, + stackedField: { + marginBottom: 16, + }, + sourceDisplay: { + display: "flex", + alignItems: "center", + minHeight: 46, + paddingInline: 12, + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadius, + color: token.colorText, + background: token.colorBgContainer, + }, + sourceLine: { + display: "flex", + alignItems: "center", + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + sourceValue: { + display: "inline-flex", + alignItems: "center", + gap: 4, + marginLeft: 8, + minWidth: 0, + verticalAlign: "middle", + }, +})); + +interface FacetSourceDisplayProps { + facetType: FacetType; + sources: FacetGridFields["sources"]; +} + +function FacetSourceDisplay({ facetType, sources }: FacetSourceDisplayProps) { + const { styles } = useStyles(); + const sourceTypeLabel = getFacetSourceTypeLabel(facetType); + const sourceLabel = + sources.length > 0 + ? sources.map((source) => source.name || source.handle).join(", ") + : getFacetSourceHandleLabel(facetType, "") ?? facetType; + const icon = getFacetTypeIcon(facetType); + + return ( +
    + + {sourceTypeLabel}: + + {icon} + {sourceLabel} + + +
    + ); +} + +const EMPTY_VALUES: EditFacetFormValues = { + label: "", + slug: "", + uiType: FacetUiType.Checkbox, + scopes: [FacetScopeType.Search, FacetScopeType.Category], +}; + +const DATA_URL_PATTERN = /^data:/i; + +function isExistingApiFileId( + fileId: string | null | undefined, +): fileId is string { + return Boolean(fileId && !DATA_URL_PATTERN.test(fileId)); +} + +function swatchMetadataInput( + metadata: unknown, +): Record | null | undefined { + if (metadata === null || metadata === undefined) { + return metadata; + } + + return typeof metadata === "object" + ? (metadata as Record) + : undefined; +} + +function facetSwatchToEditorSwatch( + swatch: FacetSwatchFields | null | undefined, +): OptionEditorSwatch | null { + if (!swatch) { + return null; + } + + return { + swatchType: swatch.swatchType, + colorOne: swatch.colorOne, + colorTwo: swatch.colorTwo, + fileId: + swatch.swatchType === SwatchType.Image ? swatch.file?.id ?? null : null, + fileUrl: + swatch.swatchType === SwatchType.Image ? swatch.file?.url ?? null : null, + metadata: swatch.metadata, + }; +} + +function facetValuesToEditorValues( + values: FacetValueGridFields[], +): FacetValueEditorRow[] { + return [...values] + .sort((first, second) => first.sortIndex - second.sortIndex) + .map((value, index) => ({ + id: value.id, + apiId: value.id, + apiSwatchId: value.swatch?.id, + kind: value.kind, + label: value.label, + handle: value.handle, + sortIndex: index, + enabled: value.enabled, + parent: value.parent, + sourceValues: value.sourceValues.map((sourceValue) => ({ + id: sourceValue.id, + label: sourceValue.label, + handle: sourceValue.handle, + })), + swatch: facetSwatchToEditorSwatch(value.swatch), + })); +} + +function normalizeValueSortIndexes( + values: FacetValueEditorRow[], +): FacetValueEditorRow[] { + return values.map((value, sortIndex) => ({ + ...value, + sortIndex, + })); +} + +function editorSwatchToCreateInput( + swatch: OptionEditorSwatch, +): ApiFacetSwatchCreateInput { + const input: ApiFacetSwatchCreateInput = { + swatchType: swatch.swatchType, + }; + const metadata = swatchMetadataInput(swatch.metadata); + + if (metadata !== undefined) { + input.metadata = metadata; + } + + if (swatch.swatchType === SwatchType.Color) { + input.colorOne = swatch.colorOne ?? null; + return input; + } + + if (swatch.swatchType === SwatchType.Gradient) { + input.colorOne = swatch.colorOne ?? null; + input.colorTwo = swatch.colorTwo ?? null; + return input; + } + + if (swatch.swatchType === SwatchType.Image) { + input.fileId = isExistingApiFileId(swatch.fileId) ? swatch.fileId : null; + } + + return input; +} + +function editorSwatchToUpdateInput( + id: string, + swatch: OptionEditorSwatch, +): ApiFacetSwatchUpdateInput { + return { + id, + ...editorSwatchToCreateInput(swatch), + }; +} + +export function EditFacetModal() { + const { styles } = useStyles(); + const { message, modal } = App.useApp(); + const { payload, pop } = useModalStackContext(); + const typedPayload = payload as IEditFacetModalPayload; + const { + facet, + loading: loadingFacet, + error, + refetch, + } = useFacet(typedPayload.facetId); + const { updateFacet, loading: saving } = useUpdateFacet(); + const { updateFacetValue } = useUpdateFacetValue(); + const { unmergeFacetValues, loading: unmergingValues } = useUnmergeFacetValues(); + const { + createFacetSwatch, + updateFacetSwatch, + loading: savingSwatch, + } = useUpsertFacetSwatch(); + const { createFacetValue, loading: creatingValue } = useCreateFacetValue(); + const { deleteFacetValue, loading: deletingValue } = useDeleteFacetValue(); + const { push: openValueGroupModal } = useFacetValueGroupModal(); + const { push: openValueCandidatesModal } = useFacetValueCandidatesModal(); + const [savingValueOrder, setSavingValueOrder] = useState(false); + const [editorValues, setEditorValues] = useState([]); + const [selectionResetKey, setSelectionResetKey] = useState(0); + const [swatchesEnabled, setSwatchesEnabled] = useState(false); + const submitInProgressRef = useRef(false); + + const methods = useForm({ + resolver: zodResolver(editFacetSchema), + defaultValues: EMPTY_VALUES, + }); + const { + control, + formState: { isDirty }, + handleSubmit, + reset, + setError, + setValue, + watch, + } = methods; + const label = watch("label"); + + useEffect(() => { + if (!facet) { + return; + } + + reset({ + label: facet.label, + slug: facet.slug, + uiType: facet.uiType, + scopes: facet.scopes, + }); + }, [facet, reset]); + + useEffect(() => { + setValue("slug", slugify(label), { shouldValidate: Boolean(label) }); + }, [label, setValue]); + + useEffect(() => { + if (!facet) { + return; + } + if (submitInProgressRef.current) { + return; + } + + setEditorValues(facetValuesToEditorValues(facet.values)); + setSwatchesEnabled(facet.values.some((value) => Boolean(value.swatch))); + }, [facet]); + + const uiTypeOptions = useMemo( + () => + facet ? getAllowedFacetUiTypes(facet.facetType) : [], + [facet], + ); + + const hasDraftValueChanges = useMemo(() => { + if (!facet) return false; + const originalIds = [...facet.values] + .sort((first, second) => first.sortIndex - second.sortIndex) + .map((value) => value.id); + const currentIds = [...editorValues] + .sort((first, second) => first.sortIndex - second.sortIndex) + .map((value) => value.id); + + if (originalIds.length !== currentIds.length) return true; + return originalIds.some((id, index) => id !== currentIds[index]); + }, [editorValues, facet]); + + const ensureNoDraftChanges = useCallback(() => { + if (!isDirty && !hasDraftValueChanges) { + return true; + } + message.warning("Save or close current changes before changing values."); + return false; + }, [hasDraftValueChanges, isDirty, message]); + + const handleOpenValueCandidates = useCallback(() => { + if (!facet) return; + if (!ensureNoDraftChanges()) return; + openValueCandidatesModal({ + facetId: facet.id, + facetType: facet.facetType, + nextSortIndex: editorValues.length, + onSaved: async () => { + await refetch(); + await typedPayload.onSaved?.(); + }, + }); + }, [ + editorValues.length, + ensureNoDraftChanges, + facet, + openValueCandidatesModal, + refetch, + typedPayload, + ]); + + const toInitialGroupedRows = useCallback( + (value: FacetValueEditorRow): FacetValueEditorRow[] => + value.sourceValues.map((sourceValue, index) => { + const existing = editorValues.find((row) => row.id === sourceValue.id); + return ( + existing ?? { + id: sourceValue.id, + apiId: sourceValue.id, + kind: FacetValueKind.Source, + label: sourceValue.label, + handle: sourceValue.handle, + sortIndex: index, + enabled: true, + parent: { + id: value.id, + label: value.label, + handle: value.handle, + }, + sourceValues: [], + swatch: null, + } + ); + }), + [editorValues], + ); + + const openGroupModal = useCallback( + ( + mode: "create" | "edit" | "add-to-existing", + selectedValues: FacetValueEditorRow[], + groupValue?: FacetValueEditorRow, + ) => { + if (!facet) return; + if (!ensureNoDraftChanges()) return; + openValueGroupModal({ + groupMode: mode, + facetId: facet.id, + selectedValues, + availableValues: editorValues, + groupValueId: groupValue?.id, + initialGroupLabel: groupValue?.label, + initialGroupedValues: groupValue ? toInitialGroupedRows(groupValue) : undefined, + onSaved: async () => { + await refetch(); + setSelectionResetKey((current) => current + 1); + await typedPayload.onSaved?.(); + }, + }); + }, + [ + editorValues, + ensureNoDraftChanges, + facet, + openValueGroupModal, + refetch, + setSelectionResetKey, + toInitialGroupedRows, + typedPayload, + ], + ); + + const handleUpdateValueSwatch = useCallback( + (valueId: string, swatch: OptionEditorSwatch) => { + setEditorValues((currentValues) => + currentValues.map((value) => + value.id === valueId ? { ...value, swatch } : value, + ), + ); + }, + [], + ); + + const onSubmit = useCallback( + async (values: EditFacetFormValues) => { + if (!facet) { + return; + } + + submitInProgressRef.current = true; + + const result = await updateFacet( + mapFacetFormToUpdateInput(facet.id, { + ...values, + slug: slugify(values.label), + }), + ); + if (result.userErrors.length > 0) { + mapFacetUserErrorsToFormErrors(result.userErrors).forEach((userError) => { + if (userError.field === "label") { + setError("label", { message: userError.message }); + } + if (userError.field === "slug") { + setError("slug", { message: userError.message }); + } + if (userError.field === "uiType") { + setError("uiType", { message: userError.message }); + } + if (userError.field === "scopes") { + setError("scopes", { message: userError.message }); + } + }); + message.error(result.userErrors[0].message); + return; + } + + try { + setSavingValueOrder(true); + for (const [sortIndex, value] of editorValues.entries()) { + const trimmedName = value.label.trim(); + if (!trimmedName) { + message.error("Value name is required."); + return; + } + + const original = value.apiId + ? facet.values.find((candidate) => candidate.id === value.apiId) + : null; + const originalSwatchId = original?.swatch?.id ?? null; + let swatchId = originalSwatchId; + const swatch = swatchesEnabled ? value.swatch ?? DEFAULT_SWATCH : null; + + if (swatch) { + const swatchResult = swatchId + ? await updateFacetSwatch( + editorSwatchToUpdateInput(swatchId, swatch), + ) + : await createFacetSwatch( + editorSwatchToCreateInput(swatch), + ); + + if (swatchResult.userErrors.length > 0) { + message.error(swatchResult.userErrors[0].message); + return; + } + + swatchId = swatchResult.facetSwatch?.id ?? swatchId; + } else { + swatchId = null; + } + + if (!value.apiId) { + const createResult = await createFacetValue({ + facetId: facet.id, + label: trimmedName, + handle: slugify(trimmedName), + enabled: true, + ...(swatchId ? { swatchId } : {}), + sortIndex, + }); + + if (createResult.userErrors.length > 0) { + message.error(createResult.userErrors[0].message); + return; + } + + continue; + } + + const labelChanged = original?.label !== trimmedName; + const canUpdateHandle = value.kind !== FacetValueKind.Source; + const slug = slugify(trimmedName); + const sortIndexChanged = original?.sortIndex !== sortIndex; + const slugChanged = canUpdateHandle && original?.handle !== slug; + const swatchChanged = originalSwatchId !== swatchId; + + if ( + !labelChanged && + !slugChanged && + !sortIndexChanged && + !swatchChanged + ) { + continue; + } + + const orderResult = await updateFacetValue({ + id: value.apiId, + ...(labelChanged ? { label: trimmedName } : {}), + ...(slugChanged ? { handle: slug } : {}), + sortIndex, + ...(swatchChanged ? { swatchId } : {}), + }); + + if (orderResult.userErrors.length > 0) { + message.error(orderResult.userErrors[0].message); + return; + } + } + } finally { + setSavingValueOrder(false); + } + + message.success("Filter updated."); + await typedPayload.onSaved?.(); + pop(); + }, + [ + facet, + createFacetValue, + createFacetSwatch, + editorValues, + message, + pop, + setError, + swatchesEnabled, + typedPayload, + updateFacet, + updateFacetSwatch, + updateFacetValue, + ], + ); + + const collectUngroupSourceIds = useCallback((rows: FacetValueEditorRow[]) => { + const ids = new Set(); + for (const row of rows) { + if (row.kind === FacetValueKind.Group) { + row.sourceValues.forEach((sourceValue) => ids.add(sourceValue.id)); + } + if (row.kind === FacetValueKind.Source && row.parent?.id) { + ids.add(row.id); + } + } + return [...ids]; + }, []); + + const handleUngroupValues = useCallback( + async (rows: FacetValueEditorRow[]) => { + const sourceValueIds = collectUngroupSourceIds(rows); + if (!ensureNoDraftChanges()) return; + if (sourceValueIds.length === 0) { + message.info("No grouped values to ungroup."); + return; + } + + const result = await unmergeFacetValues({ + sourceValueIds, + }); + if (result.userErrors.length > 0) { + message.error(result.userErrors[0].message); + return; + } + + await refetch(); + await typedPayload.onSaved?.(); + message.success("Values ungrouped."); + }, + [ + collectUngroupSourceIds, + ensureNoDraftChanges, + message, + refetch, + typedPayload, + unmergeFacetValues, + ], + ); + + const handleDeleteValues = useCallback( + (rows: FacetValueEditorRow[]) => { + if (!ensureNoDraftChanges()) return; + const groupedValue = rows.find( + (row) => row.kind === FacetValueKind.Group && row.sourceValues.length > 0, + ); + if (groupedValue) { + message.warning("Ungroup grouped values before deleting them."); + return; + } + + modal.confirm({ + title: rows.length === 1 ? "Delete value?" : "Delete values?", + content: + rows.length === 1 + ? `Delete ${rows[0].label}?` + : `Delete ${rows.length} selected values?`, + okText: "Delete", + okButtonProps: { danger: true }, + async onOk() { + for (const row of rows) { + if (!row.apiId) continue; + const result = await deleteFacetValue({ id: row.apiId }); + if (result.userErrors.length > 0) { + message.error(result.userErrors[0].message); + return Promise.reject(new Error(result.userErrors[0].message)); + } + } + await refetch(); + await typedPayload.onSaved?.(); + message.success(rows.length === 1 ? "Value deleted." : "Values deleted."); + }, + }); + }, + [deleteFacetValue, ensureNoDraftChanges, message, modal, refetch, typedPayload], + ); + + if (loadingFacet && !facet) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!facet) { + return ( + + + + ); + } + + const discrete = isDiscreteFacetType(facet.facetType); + + return ( + + + } + > + + +
    +
    Source
    + +
    +
    +
    +
    Label
    + ( + <> + + {fieldError && ( +
    {fieldError.message}
    + )} + + )} + /> +
    +
    +
    +
    Available in
    + ( + <> + + {fieldError ? ( +
    {fieldError.message}
    + ) : null} + + )} + /> +
    +
    + + {discrete ? ( + + + ( + + )} + /> + setSwatchesEnabled(true), + }, + { + key: "off", + label: "Off", + onClick: () => setSwatchesEnabled(false), + }, + ], + }} + trigger={["click"]} + > + + + + + +
    +
    + ); +} diff --git a/admin/src/domains/discovery/facets/modals/facet-value-group-modal/index.ts b/admin/src/domains/discovery/facets/modals/facet-value-group-modal/index.ts new file mode 100644 index 000000000..05a1c820d --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/facet-value-group-modal/index.ts @@ -0,0 +1 @@ +export * from "./facet-value-group-modal"; diff --git a/admin/src/domains/discovery/facets/modals/facet-value-group-modal/schema.ts b/admin/src/domains/discovery/facets/modals/facet-value-group-modal/schema.ts new file mode 100644 index 000000000..7edd64f23 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/facet-value-group-modal/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const facetValueGroupSchema = z.object({ + label: z.string().trim().min(1, "Name is required"), +}); + +export type FacetValueGroupFormInput = z.input; +export type FacetValueGroupFormValues = z.output; diff --git a/admin/src/domains/discovery/facets/modals/link-source-values-modal/index.ts b/admin/src/domains/discovery/facets/modals/link-source-values-modal/index.ts new file mode 100644 index 000000000..a779e6a33 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/link-source-values-modal/index.ts @@ -0,0 +1 @@ +export { LinkSourceValuesModal } from "./link-source-values-modal"; diff --git a/admin/src/domains/discovery/facets/modals/link-source-values-modal/link-source-values-modal.tsx b/admin/src/domains/discovery/facets/modals/link-source-values-modal/link-source-values-modal.tsx new file mode 100644 index 000000000..890f69442 --- /dev/null +++ b/admin/src/domains/discovery/facets/modals/link-source-values-modal/link-source-values-modal.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { App, Select } from "antd"; +import { createStyles } from "antd-style"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + getDuplicateSourceHandles, + normalizeSourceHandles, +} from "../../mappers"; +import type { ILinkSourceValuesModalPayload } from "../../modals"; +import { + linkSourceValuesSchema, + type LinkSourceValuesFormValues, +} from "./schema"; + +const useStyles = createStyles(({ token }) => ({ + error: { + fontSize: 12, + color: token.colorError, + marginTop: 4, + }, +})); + +export function LinkSourceValuesModal() { + const { styles } = useStyles(); + const { message } = App.useApp(); + const { payload, pop } = useModalStackContext(); + const typedPayload = payload as ILinkSourceValuesModalPayload; + + const methods = useForm({ + resolver: zodResolver(linkSourceValuesSchema), + defaultValues: { + sourceHandles: typedPayload.sourceHandles, + }, + }); + const { control, handleSubmit, setError } = methods; + + const onSubmit = useCallback( + async (values: LinkSourceValuesFormValues) => { + const duplicateHandles = getDuplicateSourceHandles(values.sourceHandles); + if (duplicateHandles.length > 0) { + setError("sourceHandles", { + message: `Duplicate source handles: ${duplicateHandles.join(", ")}`, + }); + return; + } + + const handles = normalizeSourceHandles(values.sourceHandles); + if (handles.length === 0) { + setError("sourceHandles", { + message: "At least one source handle is required", + }); + return; + } + + const accepted = await typedPayload.onSave?.(handles); + if (accepted === false) { + return; + } + + message.success("Source links updated."); + pop(); + }, + [message, pop, setError, typedPayload], + ); + + return ( + + + } + > + + + ( + <> + } /> + {errors.name ?
    {errors.name.message}
    : null} + +
    + + { inputField.ref(element); phraseInputRefs.current[index] = element?.input ?? null; }} + aria-label={`Trigger phrase ${index + 1}`} + status={errors.phrases?.[index]?.value ? "error" : undefined} + onKeyDown={(event) => { + if (event.key === "Enter" && index === phrases.fields.length - 1 && inputField.value.trim()) { event.preventDefault(); appendPhrase(); } + if (event.key === "Backspace" && !inputField.value && phrases.fields.length > 1) { event.preventDefault(); phrases.remove(index); } + }} + /> + )} + /> + {errors.phrases?.[index]?.value ?
    {errors.phrases[index]?.value?.message}
    : null} +
    + + + {typeof errors.phrases?.message === "string" ?
    {errors.phrases.message}
    : null} +
    + + + {products.length} / 50} /> + Products shown higher for matching queries. + {products.length ? ( +
    + + theme={agGridTheme} + rowData={products} + columnDefs={productColumns} + getRowId={({ data }) => data.id} + rowHeight={48} + headerHeight={40} + suppressMovableColumns + suppressCellFocus={false} + defaultColDef={{ resizable: false }} + /> +
    + ) : } + + {typeof errors.products?.message === "string" ?
    {errors.products.message}
    : null} +
    +
    + ); +} diff --git a/admin/src/domains/discovery/search/product-boosts/modals/schema.ts b/admin/src/domains/discovery/search/product-boosts/modals/schema.ts new file mode 100644 index 000000000..b4ff7c0dd --- /dev/null +++ b/admin/src/domains/discovery/search/product-boosts/modals/schema.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +const unicodeLength = (value: string) => Array.from(value).length; +const requiredText = (label: string) => + z + .string() + .trim() + .refine((value) => unicodeLength(value) > 0, `${label} is required`) + .refine( + (value) => unicodeLength(value) <= 128, + `${label} must be at most 128 characters`, + ); + +export const productBoostFormSchema = z.object({ + name: requiredText("Name"), + locale: z.string().min(1, "Locale is required"), + enabled: z.boolean(), + phrases: z + .array(z.object({ value: z.string().trim().min(1, "Phrase is required") })) + .min(1, "Add at least one trigger phrase") + .max(20, "A boost can contain at most 20 trigger phrases") + .superRefine((phrases, context) => { + const seen = new Map(); + phrases.forEach(({ value }, index) => { + const normalized = value.normalize("NFKC").trim().toLocaleLowerCase(); + const previousIndex = seen.get(normalized); + if (previousIndex !== undefined) { + context.addIssue({ + code: "custom", + path: [index, "value"], + message: `Duplicates trigger phrase ${previousIndex + 1}`, + }); + } else if (normalized) { + seen.set(normalized, index); + } + }); + }), + products: z + .array( + z.object({ + id: z.string().min(1), + title: z.string().optional(), + image: z.string().nullable().optional(), + status: z.string().optional(), + isPublished: z.boolean().optional(), + media: z.unknown().optional(), + }), + ) + .min(1, "Select at least one product") + .max(50, "A boost can contain at most 50 products") + .refine( + (products) => new Set(products.map(({ id }) => id)).size === products.length, + "Products must be unique", + ), +}); + +export type ProductBoostFormValues = z.infer; diff --git a/admin/src/domains/discovery/search/product-boosts/page/filter-schema.ts b/admin/src/domains/discovery/search/product-boosts/page/filter-schema.ts new file mode 100644 index 000000000..fba8cde10 --- /dev/null +++ b/admin/src/domains/discovery/search/product-boosts/page/filter-schema.ts @@ -0,0 +1,75 @@ +import { FilterOperator, FilterType } from "@/layouts/filters"; +import { + dateOperators, + numberOperators, + stringOperators, +} from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; + +export const filterSchema: IFilterSchema[] = [ + { + key: "name", + label: "Name", + description: "Filter by boost name", + type: FilterType.String, + operators: stringOperators, + payloadKey: "name", + }, + { + key: "phrases", + label: "Phrase", + description: "Filter by a trigger phrase", + type: FilterType.String, + operators: stringOperators, + payloadKey: "phrases", + }, + { + key: "locale", + label: "Locale", + description: "Filter by locale code", + type: FilterType.String, + operators: stringOperators, + payloadKey: "locale", + }, + { + key: "enabled", + label: "Status", + description: "Filter enabled or disabled boosts", + type: FilterType.Boolean, + operators: [FilterOperator.Eq], + payloadKey: "enabled", + }, + { + key: "phrasesCount", + label: "Phrases", + description: "Filter by trigger phrase count", + type: FilterType.Number, + operators: numberOperators, + payloadKey: "phrasesCount", + }, + { + key: "productIds", + label: "Product", + description: "Filter by boosted product", + type: FilterType.Relation, + operators: [FilterOperator.In], + payloadKey: "productIds", + entity: "product", + }, + { + key: "productsCount", + label: "Products", + description: "Filter by boosted product count", + type: FilterType.Number, + operators: numberOperators, + payloadKey: "productsCount", + }, + { + key: "updatedAt", + label: "Updated date", + description: "Filter by last update date", + type: FilterType.DateRange, + operators: dateOperators, + payloadKey: "updatedAt", + }, +]; diff --git a/admin/src/domains/discovery/search/product-boosts/page/page-config.ts b/admin/src/domains/discovery/search/product-boosts/page/page-config.ts new file mode 100644 index 000000000..20a98f1fa --- /dev/null +++ b/admin/src/domains/discovery/search/product-boosts/page/page-config.ts @@ -0,0 +1,100 @@ +import type { + FilterTransformer, + SortFieldMapping, + UsePageConfigReturn, +} from "@/hooks"; +import { + createGraphqlBooleanFilterTransformer, + createGraphqlDateTimeRangeFilterTransformer, + createGraphqlIntFilterTransformer, + createGraphqlStringFilterTransformer, +} from "@/layouts/filters"; +import type { + ApiSearchProductBoostsMetaInput, + ApiSearchProductBoostOrderByInput, + ApiSearchProductBoostWhereInput, +} from "@/graphql/types"; +import type { IFilterValue } from "@/layouts/filters/core/types"; +import { SearchProductBoostOrderField } from "@/graphql/types"; +import type { SearchProductBoostsQueryVariables } from "../graphql/operation-types"; + +export const productBoostSortFieldMapping: SortFieldMapping = { + name: SearchProductBoostOrderField.Name, + locale: SearchProductBoostOrderField.Locale, + enabled: SearchProductBoostOrderField.Enabled, + phrasesCount: SearchProductBoostOrderField.PhrasesCount, + productsCount: SearchProductBoostOrderField.ProductsCount, + updatedAt: SearchProductBoostOrderField.UpdatedAt, +}; + +export const buildProductBoostSearchCondition = ( + search: string, +): Partial => ({ + name: { _containsi: search }, +}); + +export const productBoostFilterTransformers: Record< + string, + FilterTransformer +> = { + name: createGraphqlStringFilterTransformer( + "name", + ), + phrases: + createGraphqlStringFilterTransformer( + "phrases", + ), + locale: + createGraphqlStringFilterTransformer( + "locale", + ), + enabled: + createGraphqlBooleanFilterTransformer( + "enabled", + ), + phrasesCount: + createGraphqlIntFilterTransformer( + "phrasesCount", + ), + productsCount: + createGraphqlIntFilterTransformer( + "productsCount", + ), + productIds: () => null, + updatedAt: + createGraphqlDateTimeRangeFilterTransformer( + "updatedAt", + ), +}; + +export function buildProductBoostsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn< + ApiSearchProductBoostWhereInput, + SearchProductBoostOrderField + >, + "first" | "after" | "last" | "before" | "where" | "orderBy" | "filters" + >, +): SearchProductBoostsQueryVariables { + return { + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: (pageConfig.orderBy ?? + null) as ApiSearchProductBoostOrderByInput[] | null, + meta: buildProductBoostsMeta(pageConfig.filters), + }; +} + +function buildProductBoostsMeta( + filters: IFilterValue[], +): ApiSearchProductBoostsMetaInput | null { + const productIds = filters + .filter((filter) => filter.payloadKey === "productIds") + .flatMap((filter) => Array.isArray(filter.value) ? filter.value : [filter.value]) + .filter((value): value is string => typeof value === "string" && value.length > 0); + + return productIds.length > 0 ? { productIds: [...new Set(productIds)] } : null; +} diff --git a/admin/src/domains/discovery/search/product-boosts/page/page.tsx b/admin/src/domains/discovery/search/product-boosts/page/page.tsx new file mode 100644 index 000000000..3d3d044eb --- /dev/null +++ b/admin/src/domains/discovery/search/product-boosts/page/page.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, Button, Flex, Tag, Typography } from "antd"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { useRouter } from "next/navigation"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { + AllCommunityModule, + type ColDef, + GridStateModule, + ModuleRegistry, +} from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { + ApiSearchProductBoost, + ApiSearchProductBoostWhereInput, +} from "@/graphql/types"; +import { SearchProductBoostOrderField } from "@/graphql/types"; +import { useProductBoosts } from "../hooks"; +import { filterSchema } from "./filter-schema"; +import { + buildProductBoostSearchCondition, + buildProductBoostsQueryVariables, + productBoostFilterTransformers, + productBoostSortFieldMapping, +} from "./page-config"; +import { useProductBoostModal } from "../../modals"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); + +function formatDate(value: string | null | undefined): string { + if (!value) { + return ""; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +} + +const NameCellRenderer = ( + props: CustomCellRendererProps, +) => ( + + {props.value} + +); + +const PhrasesCellRenderer = ( + props: CustomCellRendererProps, +) => { + const phrases = [...(props.data?.phrases ?? [])] + .sort((left, right) => left.position - right.position) + .map(({ phrase }) => phrase) + .join(", "); + + return ( + + {props.value ?? 0} + + {phrases || "No trigger phrases"} + + + ); +}; + +const LocaleCellRenderer = ( + props: CustomCellRendererProps, +) => {props.value?.toUpperCase()}; + +const StatusCellRenderer = ( + props: CustomCellRendererProps, +) => ( + + {props.value ? "Enabled" : "Disabled"} + +); + +const ProductsCellRenderer = ( + props: CustomCellRendererProps, +) => { + const count = props.value ?? 0; + + return ( + + {count} {count === 1 ? "product" : "products"} + + ); +}; + +const DateCellRenderer = ( + props: CustomCellRendererProps, +) => {formatDate(props.value)}; + +export default function ProductBoostsPage() { + const router = useRouter(); + const { push: openProductBoostModal } = useProductBoostModal(); + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig< + ApiSearchProductBoost, + ApiSearchProductBoostWhereInput, + SearchProductBoostOrderField + >({ + gridRef, + storageKey: "search-product-boosts-grid-state", + filterSchema, + sortFieldMapping: productBoostSortFieldMapping, + defaultPageSize: 20, + buildSearchCondition: buildProductBoostSearchCondition, + filterTransformers: productBoostFilterTransformers, + }); + + const queryVariables = useMemo( + () => buildProductBoostsQueryVariables(pageConfig), + [ + pageConfig.first, + pageConfig.after, + pageConfig.last, + pageConfig.before, + pageConfig.where, + pageConfig.orderBy, + pageConfig.filters, + ], + ); + const { productBoosts, totalCount, pageInfo, loading, error, refetch } = + useProductBoosts(queryVariables); + + const handleCreate = useCallback(() => { + openProductBoostModal({ mode: "create", onSaved: refetch }); + }, [openProductBoostModal, refetch]); + + const handleEdit = useCallback( + (entityId: string) => { + openProductBoostModal({ mode: "edit", entityId, onSaved: refetch }); + }, + [openProductBoostModal, refetch], + ); + + const handleNextPage = useCallback(() => { + if (pageInfo?.endCursor) { + pageConfig.goToNextPage(pageInfo.endCursor); + } + }, [pageConfig, pageInfo?.endCursor]); + + const handlePrevPage = useCallback(() => { + if (pageInfo?.startCursor) { + pageConfig.goToPrevPage(pageInfo.startCursor); + } + }, [pageConfig, pageInfo?.startCursor]); + + const columnDefs = useMemo[]>( + () => [ + { + headerName: "Boost", + field: "name", + cellRenderer: NameCellRenderer, + flex: 1, + minWidth: 240, + }, + { + headerName: "Trigger phrases", + field: "phrasesCount", + cellRenderer: PhrasesCellRenderer, + flex: 2, + minWidth: 320, + }, + { + headerName: "Locale", + field: "locale", + cellRenderer: LocaleCellRenderer, + minWidth: 100, + maxWidth: 130, + }, + { + headerName: "Status", + field: "enabled", + cellRenderer: StatusCellRenderer, + minWidth: 120, + maxWidth: 140, + }, + { + headerName: "Products", + field: "productsCount", + cellRenderer: ProductsCellRenderer, + minWidth: 130, + }, + { + headerName: "Updated", + field: "updatedAt", + cellRenderer: DateCellRenderer, + minWidth: 150, + }, + ], + [], + ); + + const defaultColDef = useMemo>( + () => ({ + resizable: true, + sortable: true, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }), + [], + ); + + return ( + router.back()} + actions={ + + } + > + + } + /> + +
    + {error ? : null} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={productBoosts} + loading={loading} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={56} + suppressCellFocus + suppressMovableColumns + onSortChanged={pageConfig.onSortChanged} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + onRowClicked={(event) => event.data && handleEdit(event.data.id)} + getRowStyle={() => ({ cursor: "pointer" })} + /> +
    + + +
    +
    + ); +} diff --git a/admin/src/domains/discovery/search/settings/graphql/fragments.ts b/admin/src/domains/discovery/search/settings/graphql/fragments.ts new file mode 100644 index 000000000..f65e4f169 --- /dev/null +++ b/admin/src/domains/discovery/search/settings/graphql/fragments.ts @@ -0,0 +1,28 @@ +import { gql } from "@apollo/client"; + +export const SEARCH_SETTINGS_EDITOR_FRAGMENT = gql` + fragment SearchSettingsEditorFields on SearchSettings { + version + fields { + field + weight + } + typoToleranceEnabled + outOfStockPolicy + updatedAt + } +`; + +export const SEARCH_SETTINGS_OPERATION_RESULT_FRAGMENT = gql` + fragment SearchSettingsOperationResultFields on SearchSettingsOperationResult { + type + applied + clientMutationId + entityId + errors { + code + field + message + } + } +`; diff --git a/admin/src/domains/discovery/search/settings/graphql/index.ts b/admin/src/domains/discovery/search/settings/graphql/index.ts new file mode 100644 index 000000000..dd4a1b447 --- /dev/null +++ b/admin/src/domains/discovery/search/settings/graphql/index.ts @@ -0,0 +1,4 @@ +export * from "./fragments"; +export * from "./mutations"; +export * from "./operation-types"; +export * from "./queries"; diff --git a/admin/src/domains/discovery/search/settings/graphql/mutations.ts b/admin/src/domains/discovery/search/settings/graphql/mutations.ts new file mode 100644 index 000000000..8efea78db --- /dev/null +++ b/admin/src/domains/discovery/search/settings/graphql/mutations.ts @@ -0,0 +1,35 @@ +import { gql } from "@apollo/client"; +import { + SEARCH_SETTINGS_EDITOR_FRAGMENT, + SEARCH_SETTINGS_OPERATION_RESULT_FRAGMENT, +} from "./fragments"; + +export const SEARCH_SETTINGS_UPDATE_MUTATION = gql` + mutation SearchSettingsUpdate( + $expectedVersion: Int! + $operations: SearchSettingsOperationsInput! + ) { + listingMutation { + search { + settingsUpdate( + expectedVersion: $expectedVersion + operations: $operations + ) { + settings { + ...SearchSettingsEditorFields + } + operationResults { + ...SearchSettingsOperationResultFields + } + userErrors { + code + field + message + } + } + } + } + } + ${SEARCH_SETTINGS_EDITOR_FRAGMENT} + ${SEARCH_SETTINGS_OPERATION_RESULT_FRAGMENT} +`; diff --git a/admin/src/domains/discovery/search/settings/graphql/operation-types.ts b/admin/src/domains/discovery/search/settings/graphql/operation-types.ts new file mode 100644 index 000000000..4198f6495 --- /dev/null +++ b/admin/src/domains/discovery/search/settings/graphql/operation-types.ts @@ -0,0 +1,24 @@ +import type { + ApiListingMutation, + ApiListingQuery, + ApiSearchSettings, + ApiSearchSettingsOperationsInput, + ApiSearchSettingsUpdatePayload, +} from "@/graphql/types"; + +export interface SearchSettingsEditorQueryData { + listingQuery: Pick & { + search: { settings?: ApiSearchSettings | null }; + }; +} + +export interface SearchSettingsUpdateMutationData { + listingMutation: Pick & { + search: { settingsUpdate: ApiSearchSettingsUpdatePayload }; + }; +} + +export interface SearchSettingsUpdateMutationVariables { + expectedVersion: number; + operations: ApiSearchSettingsOperationsInput; +} diff --git a/admin/src/domains/discovery/search/settings/graphql/queries.ts b/admin/src/domains/discovery/search/settings/graphql/queries.ts new file mode 100644 index 000000000..408a5d0de --- /dev/null +++ b/admin/src/domains/discovery/search/settings/graphql/queries.ts @@ -0,0 +1,15 @@ +import { gql } from "@apollo/client"; +import { SEARCH_SETTINGS_EDITOR_FRAGMENT } from "./fragments"; + +export const SEARCH_SETTINGS_EDITOR_QUERY = gql` + query SearchSettingsEditor { + listingQuery { + search { + settings { + ...SearchSettingsEditorFields + } + } + } + } + ${SEARCH_SETTINGS_EDITOR_FRAGMENT} +`; diff --git a/admin/src/domains/discovery/search/settings/hooks/index.ts b/admin/src/domains/discovery/search/settings/hooks/index.ts new file mode 100644 index 000000000..3a814110a --- /dev/null +++ b/admin/src/domains/discovery/search/settings/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./use-search-editor-context"; +export * from "./use-search-settings-navigation-guard"; +export * from "./use-update-search-settings"; diff --git a/admin/src/domains/discovery/search/settings/hooks/use-search-editor-context.ts b/admin/src/domains/discovery/search/settings/hooks/use-search-editor-context.ts new file mode 100644 index 000000000..142faf31e --- /dev/null +++ b/admin/src/domains/discovery/search/settings/hooks/use-search-editor-context.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { + SEARCH_SETTINGS_EDITOR_QUERY, + type SearchSettingsEditorQueryData, +} from "../graphql"; + +export function useSearchEditorContext(skip = false) { + const { data, previousData, loading, error, refetch } = + useQuery(SEARCH_SETTINGS_EDITOR_QUERY, { + fetchPolicy: "cache-and-network", + skip, + }); + const currentData = data ?? previousData; + + return { + settings: currentData?.listingQuery.search.settings ?? null, + loading, + hasLoaded: Boolean(currentData) || Boolean(error), + error: error ?? null, + refetch, + }; +} diff --git a/admin/src/domains/discovery/search/settings/hooks/use-search-settings-navigation-guard.ts b/admin/src/domains/discovery/search/settings/hooks/use-search-settings-navigation-guard.ts new file mode 100644 index 000000000..dc845fccf --- /dev/null +++ b/admin/src/domains/discovery/search/settings/hooks/use-search-settings-navigation-guard.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +const UNSAVED_MESSAGE = + "You have unsaved search settings. Are you sure you want to leave?"; + +export function useSearchSettingsNavigationGuard( + isDirty: boolean, + isSaving: boolean, +) { + const stateRef = useRef({ isDirty, isSaving }); + + useEffect(() => { + stateRef.current = { isDirty, isSaving }; + }, [isDirty, isSaving]); + + useEffect(() => { + const beforeUnload = (event: BeforeUnloadEvent) => { + if (!stateRef.current.isDirty && !stateRef.current.isSaving) return; + event.preventDefault(); + }; + + const click = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + + const target = event.target; + const anchor = target instanceof Element ? target.closest("a[href]") : null; + if (!(anchor instanceof HTMLAnchorElement) || anchor.target === "_blank") return; + + const destination = new URL(anchor.href, window.location.href); + if ( + destination.origin !== window.location.origin || + destination.href === window.location.href + ) { + return; + } + + if (stateRef.current.isSaving) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + if (stateRef.current.isDirty && !window.confirm(UNSAVED_MESSAGE)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + const popState = () => { + if (stateRef.current.isSaving) { + window.history.forward(); + return; + } + if (stateRef.current.isDirty && !window.confirm(UNSAVED_MESSAGE)) { + window.history.forward(); + } + }; + + window.addEventListener("beforeunload", beforeUnload); + document.addEventListener("click", click, true); + window.addEventListener("popstate", popState); + + return () => { + window.removeEventListener("beforeunload", beforeUnload); + document.removeEventListener("click", click, true); + window.removeEventListener("popstate", popState); + }; + }, []); +} diff --git a/admin/src/domains/discovery/search/settings/hooks/use-update-search-settings.ts b/admin/src/domains/discovery/search/settings/hooks/use-update-search-settings.ts new file mode 100644 index 000000000..4adc7e99f --- /dev/null +++ b/admin/src/domains/discovery/search/settings/hooks/use-update-search-settings.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiSearchSettings, + ApiSearchSettingsOperationResult, + ApiSearchSettingsOperationsInput, +} from "@/graphql/types"; +import { SearchSettingsOperationType } from "@/graphql/types"; +import { + SEARCH_SETTINGS_UPDATE_MUTATION, + type SearchSettingsUpdateMutationData, + type SearchSettingsUpdateMutationVariables, +} from "../graphql"; + +export interface SearchSettingsUpdateResult { + applied: boolean; + settings: ApiSearchSettings | null; + operationResult: ApiSearchSettingsOperationResult | null; + userErrors: ApiGenericUserError[]; +} + +export function useUpdateSearchSettings() { + const [mutate, { loading, error, reset }] = useMutation< + SearchSettingsUpdateMutationData, + SearchSettingsUpdateMutationVariables + >(SEARCH_SETTINGS_UPDATE_MUTATION); + + const updateSearchSettings = useCallback( + async ( + expectedVersion: number, + operations: ApiSearchSettingsOperationsInput, + ): Promise => { + try { + const result = await mutate({ + variables: { expectedVersion, operations }, + }); + const payload = result.data?.listingMutation.search.settingsUpdate; + const operationResult = + payload?.operationResults.find( + (item) => item.type === SearchSettingsOperationType.SettingsUpdate, + ) ?? null; + const userErrors: ApiGenericUserError[] = [ + ...(payload?.userErrors ?? []), + ...(operationResult?.errors ?? []), + ]; + + if (!operationResult) { + userErrors.push({ + code: "UNEXPECTED_OPERATION_RESULT", + message: "Search update returned an unexpected operation result.", + }); + } + + const applied = Boolean( + payload?.settings && + payload.userErrors.length === 0 && + operationResult?.applied === true && + operationResult.errors.length === 0, + ); + + if (!applied && userErrors.length === 0) { + userErrors.push({ + code: "SEARCH_SETTINGS_NOT_APPLIED", + message: "Search settings were not applied.", + }); + } + + return { + applied, + settings: applied ? (payload?.settings ?? null) : null, + operationResult, + userErrors, + }; + } catch (cause) { + const message = + cause instanceof Error ? cause.message : "An unexpected error occurred"; + return { + applied: false, + settings: null, + operationResult: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }], + }; + } + }, + [mutate], + ); + + return { + updateSearchSettings, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/discovery/search/settings/mappers/index.ts b/admin/src/domains/discovery/search/settings/mappers/index.ts new file mode 100644 index 000000000..bed23b759 --- /dev/null +++ b/admin/src/domains/discovery/search/settings/mappers/index.ts @@ -0,0 +1,2 @@ +export * from "./search-errors.mapper"; +export * from "./search-settings-form.mapper"; diff --git a/admin/src/domains/discovery/search/settings/mappers/search-errors.mapper.ts b/admin/src/domains/discovery/search/settings/mappers/search-errors.mapper.ts new file mode 100644 index 000000000..1fd499564 --- /dev/null +++ b/admin/src/domains/discovery/search/settings/mappers/search-errors.mapper.ts @@ -0,0 +1,99 @@ +import type { ApiGenericUserError, SearchField } from "@/graphql/types"; + +export type SearchEditorErrorTarget = + | "name" + | "locale" + | "enabled" + | "phrases" + | "products" + | "values" + | `phrases.${number}` + | `products.${number}` + | `values.${number}` + | "global"; + +export interface MappedSearchEditorError { + target: SearchEditorErrorTarget; + message: string; + code: string; +} + +const INDEXED_FIELD = /(phrases|productIds|values)\.(\d+)$/; + +export function mapSearchEditorErrors( + errors: ApiGenericUserError[], +): MappedSearchEditorError[] { + return errors.map((error) => { + const field = error.field?.join(".") ?? ""; + const indexed = field.match(INDEXED_FIELD); + let target: SearchEditorErrorTarget = "global"; + + if (indexed) { + const section = indexed[1] === "productIds" ? "products" : indexed[1]; + target = `${section}.${Number(indexed[2])}` as SearchEditorErrorTarget; + } else if (/name$/.test(field)) target = "name"; + else if (/locale$/.test(field)) target = "locale"; + else if (/enabled$/.test(field)) target = "enabled"; + else if (/phrases$/.test(field)) target = "phrases"; + else if (/productIds$/.test(field)) target = "products"; + else if (/values$/.test(field)) target = "values"; + + return { target, message: error.message, code: error.code ?? "UNKNOWN" }; + }); +} + +export type SearchSettingsErrorTarget = + | "fields" + | `field.${SearchField}.enabled` + | `field.${SearchField}.weight` + | "typoToleranceEnabled" + | "outOfStockPolicy" + | "versionConflict" + | "global"; + +export interface MappedSearchSettingsError { + target: SearchSettingsErrorTarget; + message: string; + code: string; +} + +export function mapSearchSettingsErrors( + errors: ApiGenericUserError[], + submittedIndexToField: SearchField[], +): MappedSearchSettingsError[] { + return errors.map((error) => { + const fieldPath = error.field?.join(".") ?? ""; + const fieldMatch = fieldPath.match( + /(?:^|\.)input\.fields(?:\.(\d+))?(?:\.(field|weight))?$/, + ); + let target: SearchSettingsErrorTarget = "global"; + + if (error.code === "VERSION_CONFLICT" || /(?:^|\.)expectedVersion$/.test(fieldPath)) { + target = "versionConflict"; + } else if (fieldMatch) { + const submittedIndex = fieldMatch[1] ? Number(fieldMatch[1]) : null; + const submittedField = + submittedIndex === null ? null : submittedIndexToField[submittedIndex]; + if (!submittedField) target = "fields"; + else if (fieldMatch[2] === "weight") { + target = `field.${submittedField}.weight`; + } else { + target = `field.${submittedField}.enabled`; + } + } else if (/(?:^|\.)input\.typoToleranceEnabled$/.test(fieldPath)) { + target = "typoToleranceEnabled"; + } else if (/(?:^|\.)input\.outOfStockPolicy$/.test(fieldPath)) { + target = "outOfStockPolicy"; + } + + return { target, message: error.message, code: error.code ?? "UNKNOWN" }; + }); +} + +export function hasVersionConflict(errors: ApiGenericUserError[]): boolean { + return errors.some( + (error) => + error.code === "VERSION_CONFLICT" || + (error.field?.join(".") ?? "").endsWith("expectedVersion"), + ); +} diff --git a/admin/src/domains/discovery/search/settings/mappers/search-settings-form.mapper.ts b/admin/src/domains/discovery/search/settings/mappers/search-settings-form.mapper.ts new file mode 100644 index 000000000..fce32783c --- /dev/null +++ b/admin/src/domains/discovery/search/settings/mappers/search-settings-form.mapper.ts @@ -0,0 +1,97 @@ +import type { + ApiSearchSettings, + ApiSearchSettingsOperationsInput, +} from "@/graphql/types"; +import { SearchField, SearchOutOfStockPolicy } from "@/graphql/types"; +import type { SearchSettingsFormValues } from "../page/types"; + +export interface SearchFieldDefinition { + field: SearchField; + label: string; + help: string; + defaultWeight: number; +} + +export const SEARCH_FIELD_DEFINITIONS: SearchFieldDefinition[] = [ + { + field: SearchField.ProductTitle, + label: "Product title", + help: "Main product name shown in the storefront.", + defaultWeight: 8, + }, + { + field: SearchField.VariantTitle, + label: "Variant title", + help: "Variant-specific title and identifying text.", + defaultWeight: 5, + }, + { + field: SearchField.VendorName, + label: "Vendor name", + help: "Brand or supplier name.", + defaultWeight: 2, + }, + { + field: SearchField.CategoryName, + label: "Category name", + help: "Category assigned to the product.", + defaultWeight: 1, + }, +]; + +export const INITIAL_SEARCH_SETTINGS_FORM_VALUES: SearchSettingsFormValues = { + fields: SEARCH_FIELD_DEFINITIONS.map(({ field, defaultWeight }) => ({ + field, + enabled: true, + weight: defaultWeight, + })), + typoToleranceEnabled: false, + outOfStockPolicy: SearchOutOfStockPolicy.Show, +}; + +export function mapSearchSettingsToFormValues( + settings: ApiSearchSettings, +): SearchSettingsFormValues { + const configuredFields = new Map( + settings.fields.map((item) => [item.field, item.weight]), + ); + + return { + fields: SEARCH_FIELD_DEFINITIONS.map(({ field, defaultWeight }) => ({ + field, + enabled: + field === SearchField.ProductTitle || configuredFields.has(field), + weight: configuredFields.get(field) ?? defaultWeight, + })), + typoToleranceEnabled: settings.typoToleranceEnabled, + outOfStockPolicy: settings.outOfStockPolicy, + }; +} + +export interface SearchSettingsSubmitMapping { + operations: ApiSearchSettingsOperationsInput; + submittedIndexToField: SearchField[]; +} + +export function mapSearchSettingsFormToOperations( + values: SearchSettingsFormValues, +): SearchSettingsSubmitMapping { + const enabledFields = values.fields.filter( + (item) => item.field === SearchField.ProductTitle || item.enabled, + ); + + return { + operations: { + settings: { + fields: enabledFields.map(({ field, weight }) => ({ field, weight })), + typoToleranceEnabled: values.typoToleranceEnabled, + outOfStockPolicy: values.outOfStockPolicy, + }, + }, + submittedIndexToField: enabledFields.map(({ field }) => field), + }; +} + +export function normalizeSearchField(field: SearchField): string { + return field.toLowerCase().replaceAll("_", "-"); +} diff --git a/admin/src/domains/discovery/search/settings/page/page.tsx b/admin/src/domains/discovery/search/settings/page/page.tsx new file mode 100644 index 000000000..e1a3c5ba6 --- /dev/null +++ b/admin/src/domains/discovery/search/settings/page/page.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useForm, useWatch, type FieldErrors } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, App, Button } from "antd"; +import { createStyles } from "antd-style"; +import type { ApiSearchSettings } from "@/graphql/types"; +import { DataLayout } from "@/layouts/data"; +import { FacetScopesSettingsPaper } from "@/domains/discovery/facets/components"; +import { + useSearchEditorContext, + useSearchSettingsNavigationGuard, + useUpdateSearchSettings, +} from "../hooks"; +import { + INITIAL_SEARCH_SETTINGS_FORM_VALUES, + mapSearchSettingsErrors, + mapSearchSettingsFormToOperations, + mapSearchSettingsToFormValues, + normalizeSearchField, + type MappedSearchSettingsError, +} from "../mappers"; +import { searchSettingsFormSchema } from "./schema"; +import { SearchSettingsPaper } from "./search-settings-paper"; +import type { SearchSettingsFormValues } from "./types"; + +const useStyles = createStyles(({ token }) => ({ + content: { + height: "100%", + overflow: "auto", + paddingBottom: token.paddingXL, + }, + wrapper: { + width: "100%", + display: "flex", + flexDirection: "column", + gap: token.padding, + }, +})); + +function focusFirstApiError(errors: MappedSearchSettingsError[]) { + const first = errors.find( + (error) => error.target !== "global" && error.target !== "versionConflict", + ); + if (!first) return; + + let testId: string | null = null; + if (first.target === "fields") { + testId = "search-field-product-title-switch"; + } else if (first.target === "typoToleranceEnabled") { + testId = "search-typo-tolerance-switch"; + } else if (first.target === "outOfStockPolicy") { + testId = "search-out-of-stock-policy"; + } else if (first.target.startsWith("field.")) { + const [, field, control] = first.target.split("."); + testId = `search-field-${normalizeSearchField( + field as Parameters[0], + )}-${control === "weight" ? "weight-input" : "switch"}`; + } + + if (!testId) return; + requestAnimationFrame(() => { + const element = document.querySelector(`[data-testid="${testId}"]`); + element?.focus(); + }); +} + +export default function SearchSettingsPage() { + const { styles } = useStyles(); + const { message, modal } = App.useApp(); + const query = useSearchEditorContext(); + const mutation = useUpdateSearchSettings(); + const saveButtonRef = useRef(null); + const hydratedRef = useRef(false); + const [baselineSettings, setBaselineSettings] = + useState(null); + const [apiErrors, setApiErrors] = useState([]); + const [versionConflict, setVersionConflict] = useState(false); + const [submittedFingerprint, setSubmittedFingerprint] = useState(null); + + const { + control, + handleSubmit, + reset, + trigger, + formState: { errors, isDirty, isValid }, + } = useForm({ + resolver: zodResolver(searchSettingsFormSchema), + defaultValues: INITIAL_SEARCH_SETTINGS_FORM_VALUES, + mode: "onChange", + }); + const draft = useWatch({ control }); + const draftFingerprint = useMemo(() => JSON.stringify(draft), [draft]); + + useSearchSettingsNavigationGuard(isDirty, mutation.loading); + + useEffect(() => { + if ( + hydratedRef.current || + !query.hasLoaded || + query.error || + mutation.loading + ) { + return; + } + + const settings = query.settings; + if (!settings) { + setBaselineSettings(null); + return; + } + + reset(mapSearchSettingsToFormValues(settings)); + setBaselineSettings(settings); + hydratedRef.current = true; + void trigger(); + }, [mutation.loading, query.error, query.hasLoaded, query.settings, reset, trigger]); + + useEffect(() => { + if (!query.hasLoaded || query.error || query.loading || query.settings) return; + + const timeout = window.setTimeout(() => { + void query.refetch(); + }, 1000); + + return () => window.clearTimeout(timeout); + }, [query.error, query.hasLoaded, query.loading, query.refetch, query.settings]); + + useEffect(() => { + if ( + submittedFingerprint && + submittedFingerprint !== draftFingerprint && + apiErrors.length > 0 + ) { + setApiErrors([]); + setSubmittedFingerprint(null); + } + }, [apiErrors.length, draftFingerprint, submittedFingerprint]); + + const retryQuery = useCallback(async () => { + try { + const result = await query.refetch(); + const settings = result.data?.listingQuery.search.settings ?? null; + if (settings) reset(mapSearchSettingsToFormValues(settings)); + setBaselineSettings(settings); + hydratedRef.current = Boolean(settings); + setApiErrors([]); + setVersionConflict(false); + void trigger(); + } catch { + // Apollo exposes the retry error through the query hook. + } + }, [query, reset, trigger]); + + const reloadLatest = useCallback(async () => { + if (isDirty) { + const confirmed = await modal.confirm({ + title: "Replace unsaved changes?", + content: "Reloading will replace this draft with the latest settings.", + okText: "Reload settings", + }); + if (!confirmed) return; + } + await retryQuery(); + }, [isDirty, modal, retryQuery]); + + const submit = useCallback( + async (values: SearchSettingsFormValues) => { + if (!query.hasLoaded || query.error || versionConflict || !baselineSettings) return; + + const mapping = mapSearchSettingsFormToOperations(values); + const fingerprint = JSON.stringify(values); + setApiErrors([]); + setSubmittedFingerprint(fingerprint); + + const result = await mutation.updateSearchSettings( + baselineSettings.version, + mapping.operations, + ); + + if (!result.applied || !result.settings || result.userErrors.length > 0) { + const mapped = mapSearchSettingsErrors( + result.userErrors, + mapping.submittedIndexToField, + ); + setApiErrors(mapped); + setVersionConflict( + mapped.some((error) => error.target === "versionConflict"), + ); + focusFirstApiError(mapped); + return; + } + + const canonicalValues = mapSearchSettingsToFormValues(result.settings); + setBaselineSettings(result.settings); + reset(canonicalValues); + setApiErrors([]); + setSubmittedFingerprint(null); + setVersionConflict(false); + message.success("Search settings saved."); + requestAnimationFrame(() => saveButtonRef.current?.focus()); + }, + [baselineSettings?.version, message, mutation, query.error, query.hasLoaded, reset, versionConflict], + ); + + const handleInvalid = useCallback( + (validationErrors: FieldErrors) => { + const fieldsGroupError = validationErrors.fields as + | { message?: string; root?: { message?: string } } + | undefined; + if (!fieldsGroupError?.message && !fieldsGroupError?.root?.message) return; + requestAnimationFrame(() => { + document + .querySelector( + '[data-testid="search-field-product-title-switch"]', + ) + ?.focus(); + }); + }, + [], + ); + + const initialLoading = query.loading && !query.hasLoaded; + const waitingForSettings = query.hasLoaded && !query.error && query.settings === null; + const saveDisabled = + !query.hasLoaded || + Boolean(query.error) || + query.loading || + mutation.loading || + !baselineSettings || + !isValid || + versionConflict || + !isDirty; + + return ( + + + Preferences + + + + + + +
    + {versionConflict ? ( + + Reload latest settings + + } + /> + ) : null} + + + + +
    +
    +
    + ); +} diff --git a/admin/src/domains/discovery/search/settings/page/schema.ts b/admin/src/domains/discovery/search/settings/page/schema.ts new file mode 100644 index 000000000..707a63f4f --- /dev/null +++ b/admin/src/domains/discovery/search/settings/page/schema.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { SearchField, SearchOutOfStockPolicy } from "@/graphql/types"; + +export const searchSettingsFormSchema = z + .object({ + fields: z.array( + z.object({ + field: z.nativeEnum(SearchField), + enabled: z.boolean(), + weight: z.number().finite(), + }), + ), + typoToleranceEnabled: z.boolean(), + outOfStockPolicy: z.nativeEnum(SearchOutOfStockPolicy), + }) + .superRefine((values, context) => { + const seen = new Set(); + + values.fields.forEach((item, index) => { + if (seen.has(item.field)) { + context.addIssue({ + code: "custom", + path: ["fields", index, "field"], + message: "Search fields must be unique.", + }); + } + seen.add(item.field); + + if (item.enabled && (item.weight <= 0 || item.weight > 100)) { + context.addIssue({ + code: "custom", + path: ["fields", index, "weight"], + message: "Weight must be greater than 0 and at most 100.", + }); + } + + if (item.field === SearchField.ProductTitle && !item.enabled) { + context.addIssue({ + code: "custom", + path: ["fields", index, "enabled"], + message: "Product title must remain enabled.", + }); + } + }); + + if (!values.fields.some((item) => item.enabled)) { + context.addIssue({ + code: "custom", + path: ["fields"], + message: "At least one search field must be enabled.", + }); + } + }); diff --git a/admin/src/domains/discovery/search/settings/page/search-settings-paper.tsx b/admin/src/domains/discovery/search/settings/page/search-settings-paper.tsx new file mode 100644 index 000000000..855af36f2 --- /dev/null +++ b/admin/src/domains/discovery/search/settings/page/search-settings-paper.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { + Controller, + useWatch, + type Control, + type FieldErrors, +} from "react-hook-form"; +import { + Alert, + Button, + Divider, + InputNumber, + Radio, + Skeleton, + Switch, + Typography, +} from "antd"; +import { createStyles } from "antd-style"; +import { SearchField, SearchOutOfStockPolicy } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { + normalizeSearchField, + SEARCH_FIELD_DEFINITIONS, +} from "../mappers"; +import type { MappedSearchSettingsError } from "../mappers"; +import type { SearchSettingsFormValues } from "./types"; + +const useStyles = createStyles(({ token }) => ({ + description: { + display: "block", + marginTop: -token.marginXS, + marginBottom: token.marginLG, + }, + tableHeader: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) 120px", + gap: token.padding, + paddingBottom: token.paddingXS, + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + fieldRow: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) 120px", + gap: token.padding, + alignItems: "start", + padding: `${token.paddingXS}px 0`, + "@media (max-width: 560px)": { + gridTemplateColumns: "1fr", + }, + }, + fieldIdentity: { + display: "grid", + gridTemplateColumns: "auto minmax(0, 1fr)", + gap: token.paddingSM, + alignItems: "start", + }, + switch: { + marginTop: 3, + }, + switchLabel: { + cursor: "pointer", + }, + switchLabelDisabled: { + cursor: "default", + }, + help: { + display: "block", + marginTop: 2, + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + error: { + color: token.colorError, + fontSize: token.fontSizeSM, + marginTop: token.marginXXS, + }, + settingRow: { + display: "flex", + justifyContent: "space-between", + alignItems: "start", + gap: token.padding, + }, + radioGroup: { + display: "flex", + flexDirection: "column", + gap: token.paddingXS, + marginTop: token.paddingXS, + }, + metadata: { + display: "block", + marginTop: token.marginLG, + }, + alert: { + marginBottom: token.margin, + }, +})); + +interface SearchSettingsPaperProps { + control: Control; + errors: FieldErrors; + apiErrors: MappedSearchSettingsError[]; + loading: boolean; + queryError: Error | null; + waitingForSettings: boolean; + updatedAt: string | null; + onRetry: () => void; +} + +function firstApiError( + errors: MappedSearchSettingsError[], + target: MappedSearchSettingsError["target"], +) { + return errors.find((error) => error.target === target)?.message; +} + +export function SearchSettingsPaper({ + control, + errors, + apiErrors, + loading, + queryError, + waitingForSettings, + updatedAt, + onRetry, +}: SearchSettingsPaperProps) { + const { styles } = useStyles(); + const fieldValues = useWatch({ control, name: "fields" }); + const globalErrors = apiErrors.filter((error) => error.target === "global"); + const fieldsValidationError = errors.fields as + | { message?: string; root?: { message?: string } } + | undefined; + const fieldsError = + fieldsValidationError?.message ?? + fieldsValidationError?.root?.message ?? + firstApiError(apiErrors, "fields"); + + return ( + + + + Choose what shoppers can search and how results are ranked. + + + {loading ? ( + + ) : queryError ? ( + Retry} + /> + ) : waitingForSettings ? ( + Check again} + /> + ) : ( + <> + {globalErrors.length ? ( + error.message).join(" ")} + /> + ) : null} + + + + {SEARCH_FIELD_DEFINITIONS.map((definition, index) => { + const normalized = normalizeSearchField(definition.field); + const isRequired = definition.field === SearchField.ProductTitle; + const switchId = `search-field-${normalized}-switch`; + const helpId = `search-field-${normalized}-help`; + const groupErrorId = fieldsError ? "search-fields-error" : undefined; + const enabledApiError = firstApiError( + apiErrors, + `field.${definition.field}.enabled`, + ); + const weightApiError = firstApiError( + apiErrors, + `field.${definition.field}.weight`, + ); + const weightError = errors.fields?.[index]?.weight?.message ?? weightApiError; + + return ( +
    +
    + ( + + )} + /> +
    + + + {definition.help} + + {enabledApiError ? ( + + ) : null} +
    +
    + ( +
    + + {weightError ? ( + + ) : null} +
    + )} + /> +
    + ); + })} + + {fieldsError ? ( + + ) : null} + + + +
    +
    + + + Retry eligible searches with typo expansion when exact search is weak. + + {firstApiError(apiErrors, "typoToleranceEnabled") ? ( +
    + {firstApiError(apiErrors, "typoToleranceEnabled")} +
    + ) : null} +
    + ( + + )} + /> +
    + + + +
    + Out-of-stock products + ( + + + Show in relevance order + + + Show after available products + + + Hide from search results + + + )} + /> + {firstApiError(apiErrors, "outOfStockPolicy") ? ( + + ) : null} +
    + + {updatedAt ? ( + + Updated {new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(updatedAt))} + + ) : null} + + )} +
    + ); +} diff --git a/admin/src/domains/discovery/search/settings/page/types.ts b/admin/src/domains/discovery/search/settings/page/types.ts new file mode 100644 index 000000000..6b0f9e3db --- /dev/null +++ b/admin/src/domains/discovery/search/settings/page/types.ts @@ -0,0 +1,13 @@ +import type { SearchField, SearchOutOfStockPolicy } from "@/graphql/types"; + +export interface SearchSettingsFieldFormValue { + field: SearchField; + enabled: boolean; + weight: number; +} + +export interface SearchSettingsFormValues { + fields: SearchSettingsFieldFormValue[]; + typoToleranceEnabled: boolean; + outOfStockPolicy: SearchOutOfStockPolicy; +} diff --git a/admin/src/domains/discovery/search/synonyms/graphql/fragments.ts b/admin/src/domains/discovery/search/synonyms/graphql/fragments.ts new file mode 100644 index 000000000..0fd2f9831 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/graphql/fragments.ts @@ -0,0 +1,34 @@ +import { gql } from "@apollo/client"; + +export const SEARCH_SYNONYM_GROUP_LIST_FRAGMENT = gql` + fragment SearchSynonymGroupListFields on SearchSynonymGroup { + id + locale + name + enabled + version + values { + value + position + } + valuesCount + createdAt + updatedAt + } +`; + +export const SEARCH_SYNONYM_GROUP_EDITOR_FRAGMENT = gql` + fragment SearchSynonymGroupEditorFields on SearchSynonymGroup { + id + locale + name + enabled + version + values { + value + position + } + createdAt + updatedAt + } +`; diff --git a/admin/src/domains/discovery/search/synonyms/graphql/index.ts b/admin/src/domains/discovery/search/synonyms/graphql/index.ts new file mode 100644 index 000000000..30f4c6413 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/graphql/index.ts @@ -0,0 +1,4 @@ +export * from "./fragments"; +export * from "./queries"; +export * from "./mutations"; +export * from "./operation-types"; diff --git a/admin/src/domains/discovery/search/synonyms/graphql/mutations.ts b/admin/src/domains/discovery/search/synonyms/graphql/mutations.ts new file mode 100644 index 000000000..0f4faa863 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/graphql/mutations.ts @@ -0,0 +1,61 @@ +import { gql } from "@apollo/client"; +import { SEARCH_SYNONYM_GROUP_EDITOR_FRAGMENT } from "./fragments"; + +export const SEARCH_SYNONYM_GROUP_CREATE_MUTATION = gql` + mutation SearchSynonymGroupCreate($input: SearchSynonymGroupCreateInput!) { + listingMutation { + search { + synonymGroupCreate(input: $input) { + synonymGroup { + ...SearchSynonymGroupEditorFields + } + userErrors { + code + field + message + } + } + } + } + } + ${SEARCH_SYNONYM_GROUP_EDITOR_FRAGMENT} +`; + +export const SEARCH_SYNONYM_GROUP_UPDATE_MUTATION = gql` + mutation SearchSynonymGroupUpdate($input: SearchSynonymGroupUpdateInput!) { + listingMutation { + search { + synonymGroupUpdate(input: $input) { + synonymGroup { + ...SearchSynonymGroupEditorFields + } + userErrors { + code + field + message + } + } + } + } + } + ${SEARCH_SYNONYM_GROUP_EDITOR_FRAGMENT} +`; + +export const SEARCH_SYNONYM_GROUP_DELETE_MUTATION = gql` + mutation SearchSynonymGroupDelete($input: SearchConfigurationDeleteInput!) { + listingMutation { + search { + synonymGroupDelete(input: $input) { + synonymGroup { + id + } + userErrors { + code + field + message + } + } + } + } + } +`; diff --git a/admin/src/domains/discovery/search/synonyms/graphql/operation-types.ts b/admin/src/domains/discovery/search/synonyms/graphql/operation-types.ts new file mode 100644 index 000000000..d22702cb2 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/graphql/operation-types.ts @@ -0,0 +1,67 @@ +import type { + ApiListingQuery, + ApiListingSearchQuery, + ApiSearchSettings, + ApiSearchConfigurationDeleteInput, + ApiSearchSynonymGroup, + ApiSearchSynonymGroupConnection, + ApiSearchSynonymGroupOrderByInput, + ApiSearchSynonymGroupCreateInput, + ApiSearchSynonymGroupPayload, + ApiSearchSynonymGroupUpdateInput, + ApiSearchSynonymGroupWhereInput, +} from "@/graphql/types"; + +export interface SearchSynonymGroupsQueryData { + listingQuery: Pick & { + search: Pick & { + synonymGroups: ApiSearchSynonymGroupConnection; + }; + }; +} + +export interface SearchSynonymGroupCreateMutationData { + listingMutation: { search: { synonymGroupCreate: ApiSearchSynonymGroupPayload } }; +} + +export interface SearchSynonymGroupCreateMutationVariables { + input: ApiSearchSynonymGroupCreateInput; +} + +export interface SearchSynonymGroupUpdateMutationData { + listingMutation: { search: { synonymGroupUpdate: ApiSearchSynonymGroupPayload } }; +} + +export interface SearchSynonymGroupUpdateMutationVariables { + input: ApiSearchSynonymGroupUpdateInput; +} + +export interface SearchSynonymGroupDeleteMutationData { + listingMutation: { search: { synonymGroupDelete: ApiSearchSynonymGroupPayload } }; +} + +export interface SearchSynonymGroupDeleteMutationVariables { + input: ApiSearchConfigurationDeleteInput; +} + +export interface SearchSynonymGroupEditorQueryData { + listingQuery: Pick & { + search: Pick & { + settings?: ApiSearchSettings | null; + synonymGroup?: ApiSearchSynonymGroup | null; + }; + }; +} + +export interface SearchSynonymGroupEditorQueryVariables { + id: string; +} + +export interface SearchSynonymGroupsQueryVariables { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: ApiSearchSynonymGroupWhereInput | null; + orderBy?: ApiSearchSynonymGroupOrderByInput[] | null; +} diff --git a/admin/src/domains/discovery/search/synonyms/graphql/queries.ts b/admin/src/domains/discovery/search/synonyms/graphql/queries.ts new file mode 100644 index 000000000..36351fb21 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/graphql/queries.ts @@ -0,0 +1,62 @@ +import { gql } from "@apollo/client"; +import { + SEARCH_SYNONYM_GROUP_EDITOR_FRAGMENT, + SEARCH_SYNONYM_GROUP_LIST_FRAGMENT, +} from "./fragments"; +import { SEARCH_SETTINGS_EDITOR_FRAGMENT } from "../../graphql"; + +export const SEARCH_SYNONYM_GROUPS_QUERY = gql` + query SearchSynonymGroups( + $first: Int + $after: String + $last: Int + $before: String + $where: SearchSynonymGroupWhereInput + $orderBy: [SearchSynonymGroupOrderByInput!] + ) { + listingQuery { + search { + synonymGroups( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...SearchSynonymGroupListFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + } + ${SEARCH_SYNONYM_GROUP_LIST_FRAGMENT} +`; + +export const SEARCH_SYNONYM_GROUP_EDITOR_QUERY = gql` + query SearchSynonymGroupEditor($id: ID!) { + listingQuery { + search { + settings { + ...SearchSettingsEditorFields + } + synonymGroup(id: $id) { + ...SearchSynonymGroupEditorFields + } + } + } + } + ${SEARCH_SETTINGS_EDITOR_FRAGMENT} + ${SEARCH_SYNONYM_GROUP_EDITOR_FRAGMENT} +`; diff --git a/admin/src/domains/discovery/search/synonyms/hooks/index.ts b/admin/src/domains/discovery/search/synonyms/hooks/index.ts new file mode 100644 index 000000000..010657f81 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./use-synonym-groups"; +export * from "./use-synonym-group"; +export * from "./use-create-synonym-group"; +export * from "./use-update-synonym-group"; +export * from "./use-delete-synonym-group"; diff --git a/admin/src/domains/discovery/search/synonyms/hooks/use-create-synonym-group.ts b/admin/src/domains/discovery/search/synonyms/hooks/use-create-synonym-group.ts new file mode 100644 index 000000000..0b8a1a9fd --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/hooks/use-create-synonym-group.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiGenericUserError, ApiSearchSynonymGroupCreateInput } from "@/graphql/types"; +import { SEARCH_SYNONYM_GROUP_CREATE_MUTATION } from "../graphql"; +import type { + SearchSynonymGroupCreateMutationData, + SearchSynonymGroupCreateMutationVariables, +} from "../graphql"; + +export function useCreateSynonymGroup() { + const [mutate, { loading, error, reset }] = useMutation< + SearchSynonymGroupCreateMutationData, + SearchSynonymGroupCreateMutationVariables + >(SEARCH_SYNONYM_GROUP_CREATE_MUTATION); + + const createSynonymGroup = useCallback( + async (input: ApiSearchSynonymGroupCreateInput) => { + try { + const result = await mutate({ + variables: { input }, + refetchQueries: ["SearchSynonymGroups"], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.search.synonymGroupCreate; + return { + synonymGroup: payload?.synonymGroup ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "An unexpected error occurred"; + return { + synonymGroup: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] satisfies ApiGenericUserError[], + }; + } + }, + [mutate], + ); + + return { createSynonymGroup, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/search/synonyms/hooks/use-delete-synonym-group.ts b/admin/src/domains/discovery/search/synonyms/hooks/use-delete-synonym-group.ts new file mode 100644 index 000000000..0573e5182 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/hooks/use-delete-synonym-group.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiSearchConfigurationDeleteInput, +} from "@/graphql/types"; +import { SEARCH_SYNONYM_GROUP_DELETE_MUTATION } from "../graphql"; +import type { + SearchSynonymGroupDeleteMutationData, + SearchSynonymGroupDeleteMutationVariables, +} from "../graphql"; + +export function useDeleteSynonymGroup() { + const [mutate, { loading, error, reset }] = useMutation< + SearchSynonymGroupDeleteMutationData, + SearchSynonymGroupDeleteMutationVariables + >(SEARCH_SYNONYM_GROUP_DELETE_MUTATION); + + const deleteSynonymGroup = useCallback( + async (input: ApiSearchConfigurationDeleteInput) => { + try { + const result = await mutate({ + variables: { input }, + refetchQueries: ["SearchSynonymGroups"], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.search.synonymGroupDelete; + + return { + synonymGroup: payload?.synonymGroup ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (cause) { + const message = + cause instanceof Error + ? cause.message + : "An unexpected error occurred"; + + return { + synonymGroup: null, + userErrors: [ + { code: "UNEXPECTED_ERROR", message }, + ] satisfies ApiGenericUserError[], + }; + } + }, + [mutate], + ); + + return { deleteSynonymGroup, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/search/synonyms/hooks/use-synonym-group.ts b/admin/src/domains/discovery/search/synonyms/hooks/use-synonym-group.ts new file mode 100644 index 000000000..4c7b27821 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/hooks/use-synonym-group.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import { SEARCH_SYNONYM_GROUP_EDITOR_QUERY } from "../graphql"; +import type { + SearchSynonymGroupEditorQueryData, + SearchSynonymGroupEditorQueryVariables, +} from "../graphql/operation-types"; + +export function useSynonymGroup(id: string | undefined, skip = false) { + const { data, previousData, loading, error, refetch } = useQuery< + SearchSynonymGroupEditorQueryData, + SearchSynonymGroupEditorQueryVariables + >(SEARCH_SYNONYM_GROUP_EDITOR_QUERY, { + variables: { id: id ?? "" }, + skip: skip || !id, + fetchPolicy: "cache-and-network", + }); + const currentData = data ?? previousData; + + return { + synonymGroup: currentData?.listingQuery.search.synonymGroup ?? null, + settings: currentData?.listingQuery.search.settings ?? null, + loading, + error: error ?? null, + refetch, + }; +} diff --git a/admin/src/domains/discovery/search/synonyms/hooks/use-synonym-groups.ts b/admin/src/domains/discovery/search/synonyms/hooks/use-synonym-groups.ts new file mode 100644 index 000000000..8cfc4ffe5 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/hooks/use-synonym-groups.ts @@ -0,0 +1,70 @@ +"use client"; + +import type { + ApiPageInfo, + ApiSearchSynonymGroup, + ApiSearchSynonymGroupConnection, + ApiSearchSynonymGroupOrderByInput, + ApiSearchSynonymGroupWhereInput, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { SEARCH_SYNONYM_GROUPS_QUERY } from "../graphql"; +import type { + SearchSynonymGroupsQueryData, + SearchSynonymGroupsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseSynonymGroupsOptions + extends RelayCursorPaginationVariables { + where?: ApiSearchSynonymGroupWhereInput | null; + orderBy?: ApiSearchSynonymGroupOrderByInput[] | null; + skip?: boolean; +} + +export interface UseSynonymGroupsReturn { + synonymGroups: ApiSearchSynonymGroup[]; + connection: ApiSearchSynonymGroupConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useSynonymGroups( + options: UseSynonymGroupsOptions = {}, +): UseSynonymGroupsReturn { + const { + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + skip = false, + } = options; + + const result = useRelayConnectionQuery< + SearchSynonymGroupsQueryData, + SearchSynonymGroupsQueryVariables, + ApiSearchSynonymGroup, + ApiSearchSynonymGroupConnection + >({ + query: SEARCH_SYNONYM_GROUPS_QUERY, + variables: { first, after, last, before, where, orderBy }, + skip, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.listingQuery.search.synonymGroups, + }); + + return { + synonymGroups: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/admin/src/domains/discovery/search/synonyms/hooks/use-update-synonym-group.ts b/admin/src/domains/discovery/search/synonyms/hooks/use-update-synonym-group.ts new file mode 100644 index 000000000..82e0cba18 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/hooks/use-update-synonym-group.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiGenericUserError, ApiSearchSynonymGroupUpdateInput } from "@/graphql/types"; +import { SEARCH_SYNONYM_GROUP_UPDATE_MUTATION } from "../graphql"; +import type { + SearchSynonymGroupUpdateMutationData, + SearchSynonymGroupUpdateMutationVariables, +} from "../graphql"; + +export function useUpdateSynonymGroup() { + const [mutate, { loading, error, reset }] = useMutation< + SearchSynonymGroupUpdateMutationData, + SearchSynonymGroupUpdateMutationVariables + >(SEARCH_SYNONYM_GROUP_UPDATE_MUTATION); + + const updateSynonymGroup = useCallback( + async (input: ApiSearchSynonymGroupUpdateInput) => { + try { + const result = await mutate({ + variables: { input }, + refetchQueries: ["SearchSynonymGroups"], + awaitRefetchQueries: true, + }); + const payload = result.data?.listingMutation.search.synonymGroupUpdate; + return { + synonymGroup: payload?.synonymGroup ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "An unexpected error occurred"; + return { + synonymGroup: null, + userErrors: [{ code: "UNEXPECTED_ERROR", message }] satisfies ApiGenericUserError[], + }; + } + }, + [mutate], + ); + + return { updateSynonymGroup, loading, error: error ?? null, reset }; +} diff --git a/admin/src/domains/discovery/search/synonyms/mappers/index.ts b/admin/src/domains/discovery/search/synonyms/mappers/index.ts new file mode 100644 index 000000000..e419597f7 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/mappers/index.ts @@ -0,0 +1 @@ +export * from "./synonym-group-form.mapper"; diff --git a/admin/src/domains/discovery/search/synonyms/mappers/synonym-group-form.mapper.ts b/admin/src/domains/discovery/search/synonyms/mappers/synonym-group-form.mapper.ts new file mode 100644 index 000000000..b6c9dca4c --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/mappers/synonym-group-form.mapper.ts @@ -0,0 +1,31 @@ +import type { + ApiSearchSynonymGroupCreateInput, + ApiSearchSynonymGroupUpdateInput, +} from "@/graphql/types"; +import type { SynonymGroupFormValues } from "../modals/schema"; + +function synonymGroupValues(values: SynonymGroupFormValues) { + return { + locale: values.locale as never, + name: values.name.trim(), + enabled: values.enabled, + values: values.values.map(({ value }) => value.trim()), + }; +} + +export function buildSynonymGroupCreateInput( + values: SynonymGroupFormValues, +): ApiSearchSynonymGroupCreateInput { + return { + clientMutationId: crypto.randomUUID(), + ...synonymGroupValues(values), + }; +} + +export function buildSynonymGroupUpdateInput( + values: SynonymGroupFormValues, + id: string, + expectedVersion: number, +): ApiSearchSynonymGroupUpdateInput { + return { id, expectedVersion, ...synonymGroupValues(values) }; +} diff --git a/admin/src/domains/discovery/search/synonyms/modals/index.ts b/admin/src/domains/discovery/search/synonyms/modals/index.ts new file mode 100644 index 000000000..550e38ee0 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/modals/index.ts @@ -0,0 +1 @@ +export * from "./synonym-group-modal"; diff --git a/admin/src/domains/discovery/search/synonyms/modals/schema.ts b/admin/src/domains/discovery/search/synonyms/modals/schema.ts new file mode 100644 index 000000000..25c1b841a --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/modals/schema.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +const unicodeLength = (value: string) => Array.from(value).length; + +export const synonymGroupFormSchema = z.object({ + name: z + .string() + .trim() + .min(1, "Name is required") + .refine((value) => unicodeLength(value) <= 128, "Name must be at most 128 characters"), + locale: z.string().min(1, "Locale is required"), + enabled: z.boolean(), + values: z + .array( + z.object({ + rowId: z.string().min(1), + value: z.string().trim().min(1, "Synonym is required"), + }), + ) + .min(2, "Add at least two synonyms") + .max(20, "A group can contain at most 20 synonyms") + .superRefine((values, context) => { + const seen = new Map(); + values.forEach(({ value }, index) => { + const normalized = value.normalize("NFKC").trim().toLocaleLowerCase(); + const previousIndex = seen.get(normalized); + if (previousIndex !== undefined) { + context.addIssue({ + code: "custom", + path: [index, "value"], + message: `Duplicates synonym ${previousIndex + 1}`, + }); + } else if (normalized) { + seen.set(normalized, index); + } + }); + }), +}); + +export type SynonymGroupFormValues = z.infer; +export type SynonymEditorRow = SynonymGroupFormValues["values"][number]; diff --git a/admin/src/domains/discovery/search/synonyms/modals/synonym-group-modal.tsx b/admin/src/domains/discovery/search/synonyms/modals/synonym-group-modal.tsx new file mode 100644 index 000000000..0f2e9c7c6 --- /dev/null +++ b/admin/src/domains/discovery/search/synonyms/modals/synonym-group-modal.tsx @@ -0,0 +1,429 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { + AllCommunityModule, + type ColDef, + ModuleRegistry, + type RowDragEndEvent, +} from "ag-grid-community"; +import { + Alert, + App, + Button, + Flex, + Input, + Select, + Skeleton, + Switch, + Typography, +} from "antd"; +import { LuArrowDown as ArrowDownOutlined, LuArrowUp as ArrowUpOutlined, LuTrash2 as DeleteOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; +import { createStyles } from "antd-style"; +import { ModalHeader, ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useAgGridTheme } from "@/hooks"; +import { useStore } from "@/domains/workspace"; +import { useSearchEditorContext } from "../../hooks"; +import { hasVersionConflict, mapSearchEditorErrors } from "../../mappers"; +import type { ISynonymGroupModalPayload } from "../../modals"; +import { + useCreateSynonymGroup, + useDeleteSynonymGroup, + useSynonymGroup, + useUpdateSynonymGroup, +} from "../hooks"; +import { + buildSynonymGroupCreateInput, + buildSynonymGroupUpdateInput, +} from "../mappers"; +import { + synonymGroupFormSchema, + type SynonymEditorRow, + type SynonymGroupFormValues, +} from "./schema"; + +ModuleRegistry.registerModules([AllCommunityModule]); + +const useStyles = createStyles(({ token }) => ({ + fields: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) minmax(220px, 1fr)", + gap: token.padding, + "@media (max-width: 640px)": { gridTemplateColumns: "1fr" }, + }, + label: { display: "block", marginBottom: 6, fontWeight: 500 }, + error: { color: token.colorError, fontSize: 12, marginTop: 4 }, + grid: { width: "100%" }, + invalidCell: { color: token.colorError }, + cellError: { fontSize: 10, lineHeight: 1.1, color: token.colorError }, + sectionHelp: { color: token.colorTextSecondary, fontSize: 13 }, + preview: { + padding: token.paddingSM, + marginTop: token.marginSM, + borderRadius: token.borderRadius, + background: token.colorFillAlter, + }, +})); + +const createRow = (value = ""): SynonymEditorRow => ({ + rowId: crypto.randomUUID(), + value, +}); + +function SynonymCell( + props: CustomCellRendererProps & { error?: string }, +) { + return ( + + + {props.value || "Click to edit"} + + {props.error ? {props.error} : null} + + ); +} + +export function SynonymGroupModal() { + const { styles } = useStyles(); + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const { message, modal } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const typedPayload = payload as ISynonymGroupModalPayload; + const isEdit = typedPayload.mode === "edit"; + const store = useStore(); + const contextQuery = useSearchEditorContext(isEdit); + const detailQuery = useSynonymGroup(typedPayload.entityId, !isEdit); + const { createSynonymGroup, loading: creating } = useCreateSynonymGroup(); + const { updateSynonymGroup, loading: updating } = useUpdateSynonymGroup(); + const { deleteSynonymGroup, loading: deleting } = useDeleteSynonymGroup(); + const saving = creating || updating; + const [globalErrors, setGlobalErrors] = useState([]); + const [versionConflict, setVersionConflict] = useState(false); + + const { + control, + handleSubmit, + reset, + setValue, + setError, + clearErrors, + formState: { errors, isDirty, isValid }, + } = useForm({ + resolver: zodResolver(synonymGroupFormSchema), + defaultValues: { + name: "", + locale: "", + enabled: true, + values: [createRow(), createRow()], + }, + mode: "onChange", + }); + const valueFields = useFieldArray({ control, name: "values", keyName: "formId" }); + const rows = useWatch({ control, name: "values" }); + + useEffect(() => setDirty(isDirty), [isDirty, setDirty]); + + useEffect(() => { + if (!isEdit || !detailQuery.synonymGroup) return; + const group = detailQuery.synonymGroup; + reset({ + name: group.name, + locale: group.locale, + enabled: group.enabled, + values: [...group.values] + .sort((left, right) => left.position - right.position) + .map(({ value }) => createRow(value)), + }); + }, [detailQuery.synonymGroup, isEdit, reset]); + + const settings = isEdit ? detailQuery.settings : contextQuery.settings; + const loading = isEdit ? detailQuery.loading : contextQuery.loading; + const loadError = isEdit ? detailQuery.error : contextQuery.error; + const localeOptions = useMemo( + () => + (store?.locales ?? []).map((locale) => ({ + value: locale, + label: `${new Intl.DisplayNames(["en"], { type: "language" }).of(locale) ?? locale} (${locale})`, + })), + [store?.locales], + ); + + const focusRow = useCallback((index: number) => { + requestAnimationFrame(() => { + gridRef.current?.api.setFocusedCell(index, "value"); + gridRef.current?.api.startEditingCell({ rowIndex: index, colKey: "value" }); + }); + }, []); + + const appendRow = useCallback(() => { + if (rows.length >= 20) return; + valueFields.append(createRow()); + focusRow(rows.length); + }, [focusRow, rows.length, valueFields]); + + const removeRow = useCallback( + (rowId: string) => { + if (rows.length <= 2) return; + const index = rows.findIndex((row) => row.rowId === rowId); + if (index < 0) return; + valueFields.remove(index); + clearErrors("values"); + focusRow(Math.max(0, Math.min(index, rows.length - 2))); + }, + [clearErrors, focusRow, rows, valueFields], + ); + + const moveRow = useCallback( + (from: number, to: number) => { + if (to < 0 || to >= rows.length) return; + valueFields.move(from, to); + clearErrors("values"); + focusRow(to); + }, + [clearErrors, focusRow, rows.length, valueFields], + ); + + const columnDefs = useMemo[]>( + () => [ + { headerName: "", rowDrag: true, width: 42, sortable: false, resizable: false }, + { + headerName: "#", + valueGetter: ({ node }) => (node?.rowIndex ?? 0) + 1, + width: 54, + editable: false, + sortable: false, + }, + { + headerName: "Synonym", + field: "value", + colId: "value", + flex: 1, + minWidth: 240, + editable: true, + singleClickEdit: true, + cellRenderer: (props: CustomCellRendererProps) => { + const index = rows.findIndex((row) => row.rowId === props.data?.rowId); + return ; + }, + cellClass: ({ data }) => { + const index = rows.findIndex((row) => row.rowId === data?.rowId); + return errors.values?.[index]?.value ? styles.invalidCell : undefined; + }, + }, + { + headerName: "Actions", + width: 140, + sortable: false, + cellRenderer: ({ data }: CustomCellRendererProps) => { + const index = rows.findIndex((row) => row.rowId === data?.rowId); + if (!data || index < 0) return null; + return ( + + } /> +
    + ); + } + + return ( + Delete : null} />} + > + {loadError ? : null} + {globalErrors.length ? : null} + {versionConflict ? Reload latest data} /> : null} + {!settings ? window.location.assign(window.location.pathname.replace(/\/search\/synonyms$/, "/search/settings"))}>Open search settings} /> : null} + + + +
    +
    + + } /> + {errors.name ?
    {errors.name.message}
    : null} +
    +
    + + + + + ); +}; + +export default EditConfigurationModal; diff --git a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/actions-cell-renderer.tsx b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/actions-cell-renderer.tsx similarity index 93% rename from admin/src/domains/promos/bundles/modals/edit-groups-modal/components/actions-cell-renderer.tsx rename to admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/actions-cell-renderer.tsx index f41f51386..aa4e72985 100644 --- a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/actions-cell-renderer.tsx +++ b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/actions-cell-renderer.tsx @@ -1,15 +1,10 @@ import { Button, Dropdown } from "antd"; -import { - PlusOutlined, - DeleteOutlined, - MoreOutlined, - CopyOutlined, - SettingOutlined, -} from "@ant-design/icons"; +import { LuPlus as PlusOutlined, LuTrash2 as DeleteOutlined, LuEllipsis as MoreOutlined, LuCopy as CopyOutlined, LuSettings as SettingOutlined } from "react-icons/lu"; import type { ICellRendererParams } from "ag-grid-community"; import type { MenuProps } from "antd"; import { useStyles } from "../edit-groups-modal.styles"; import type { ITableRow } from "../types"; +import { BundleItemType } from "@/graphql/types"; export interface IActionsCellRendererParams extends ICellRendererParams { @@ -72,7 +67,7 @@ export const ActionsCellRenderer = (params: IActionsCellRendererParams) => { } // Item actions (product or variant) - if (data.itemType === "VARIANT") { + if (data.itemType === BundleItemType.Variant) { const menuItems: MenuProps["items"] = [ ...(onShowAsProduct ? [ diff --git a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/bundle-groups-grid.tsx b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/bundle-groups-grid.tsx similarity index 90% rename from admin/src/domains/promos/bundles/modals/edit-groups-modal/components/bundle-groups-grid.tsx rename to admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/bundle-groups-grid.tsx index 3c357a00f..8a9b936f7 100644 --- a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/bundle-groups-grid.tsx +++ b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/bundle-groups-grid.tsx @@ -9,13 +9,13 @@ import { YES_NO_OPTIONS, } from "@/shared/components/editor-grid/cells"; import { useTreeTableDragDrop } from "@/hooks"; -import { useBundleItemVariantSettingsModal } from "@/domains/promos/bundles/modals"; +import { useBundleItemVariantSettingsModal } from "@/domains/inventory/bundles/modals"; import "@/shared/components/entity-picker-modal/register"; import { useProductPicker, - type IPickableEntity, } from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; import type { ITableRow } from "../types"; import { @@ -29,9 +29,8 @@ import type { BundleItem, PricingRuleTemplate, } from "../../../types"; +import { BundleItemType, BundlePriceType } from "@/graphql/types"; import { - BundleItemType, - BundlePriceType, PRICE_RULE_OPTIONS, } from "../../../types"; import type { ApiProduct } from "@/graphql/types"; @@ -148,7 +147,7 @@ export const rowsToGroups = (rows: ITableRow[]): IBundleGroup[] => { minQty: itemRow.minQty ?? null, maxQty: itemRow.maxQty ?? null, pricingRule: itemRow.pricingRule ?? { - priceType: BundlePriceType.BASE, + priceType: BundlePriceType.Base, priceValue: null, }, visible: itemRow.visible ?? "yes", @@ -224,7 +223,7 @@ export const BundleGroupsGrid = forwardRef { if (!addingToGroupId) return []; return allRows - .filter((r) => r.parentId === addingToGroupId && r.itemType === "PRODUCT") + .filter((r) => r.parentId === addingToGroupId && r.itemType === BundleItemType.Product) .map((r) => r.assignedProduct?.id) .filter(Boolean) as string[]; }, [allRows, addingToGroupId]); @@ -250,14 +249,14 @@ export const BundleGroupsGrid = forwardRef { - if (row.itemType !== "PRODUCT" || !row.assignedProduct) return; + if (row.itemType !== BundleItemType.Product || !row.assignedProduct) return; const assignedProduct = row.assignedProduct; const variantsFromConnection = @@ -367,8 +366,8 @@ export const BundleGroupsGrid = forwardRef ({ - id: v.id, - title: v.title ?? v.sku ?? v.id, - sku: v.sku ?? "", - price: - typeof v.price?.amountMinor === "bigint" - ? Number(v.price.amountMinor) - : typeof v.price?.amountMinor === "number" - ? v.price.amountMinor - : 0, - stock: v.stock?.[0]?.quantityOnHand ?? 0, - options: v.selectedOptions?.map((o) => ({ - optionId: o.optionId, - value: o.optionValueId, - })), - })), + variants: variantsFromConnection.map((v) => { + const sku = v.inventoryItem?.sku ?? ""; + + return { + id: v.id, + title: v.title ?? (sku || v.id), + sku, + price: + typeof v.price?.amountMinor === "bigint" + ? Number(v.price.amountMinor) + : typeof v.price?.amountMinor === "number" + ? v.price.amountMinor + : 0, + stock: v.inventoryItem?.stock?.[0]?.quantityOnHand ?? 0, + options: v.selectedOptions?.map((o) => ({ + optionId: o.optionId, + value: o.optionValueId, + })), + }; + }), options: assignedProduct.options?.map((o) => ({ id: o.id, name: o.name, @@ -418,7 +421,7 @@ export const BundleGroupsGrid = forwardRef { - if (row.itemType !== "PRODUCT" || !row.assignedProduct) return; + if (row.itemType !== BundleItemType.Product || !row.assignedProduct) return; const assignedProduct = row.assignedProduct; const variantsFromConnection = @@ -434,23 +437,27 @@ export const BundleGroupsGrid = forwardRef ({ - id: `item-${Date.now()}-${index}`, - type: "item" as const, - name: variant.title ?? variant.sku ?? "Unknown Variant", - parentId: row.parentId, - sortIndex: row.sortIndex + index, - level: 1, - itemType: "VARIANT" as const, - assignedVariant: variant, - minQty: row.minQty, - maxQty: row.maxQty, - pricingRule: row.pricingRule, - title: row.title, - featuredImage: row.featuredImage, - visible: row.visible ?? "yes", - selected: row.selected ?? "no", - }) + (variant, index) => { + const sku = variant.inventoryItem?.sku; + + return { + id: `item-${Date.now()}-${index}`, + type: "item" as const, + name: variant.title ?? sku ?? "Unknown Variant", + parentId: row.parentId, + sortIndex: row.sortIndex + index, + level: 1, + itemType: BundleItemType.Variant, + assignedVariant: variant, + minQty: row.minQty, + maxQty: row.maxQty, + pricingRule: row.pricingRule, + title: row.title, + featuredImage: row.featuredImage, + visible: row.visible ?? "yes", + selected: row.selected ?? "no", + }; + }, ); const newRows = [...prev]; @@ -473,7 +480,7 @@ export const BundleGroupsGrid = forwardRef { - if (row.itemType !== "VARIANT" || !row.assignedVariant) return; + if (row.itemType !== BundleItemType.Variant || !row.assignedVariant) return; const productId = row.assignedVariant.product?.id; if (!productId) return; @@ -490,7 +497,7 @@ export const BundleGroupsGrid = forwardRef { const firstVariantIndex = prev.findIndex( (r) => - r.itemType === "VARIANT" && + r.itemType === BundleItemType.Variant && r.assignedVariant?.product?.id === productId ); if (firstVariantIndex === -1) return prev; @@ -498,7 +505,7 @@ export const BundleGroupsGrid = forwardRef !( - r.itemType === "VARIANT" && + r.itemType === BundleItemType.Variant && r.assignedVariant?.product?.id === productId ) ); diff --git a/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/index.ts b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/index.ts new file mode 100644 index 000000000..68ec87dca --- /dev/null +++ b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/index.ts @@ -0,0 +1,7 @@ +export { NameCellRenderer } from "./name-cell-renderer"; + +export { ActionsCellRenderer } from "./actions-cell-renderer"; + +export { PriceRuleCellRenderer, PriceValueCellRenderer } from "./price-rule-cell-renderer"; + +export { BundleGroupsGrid, rowsToGroups } from "./bundle-groups-grid"; diff --git a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/name-cell-renderer.tsx b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/name-cell-renderer.tsx similarity index 85% rename from admin/src/domains/promos/bundles/modals/edit-groups-modal/components/name-cell-renderer.tsx rename to admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/name-cell-renderer.tsx index aa0361774..c6d20004b 100644 --- a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/name-cell-renderer.tsx +++ b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/name-cell-renderer.tsx @@ -1,14 +1,9 @@ -import { - FolderOutlined, - FolderOpenOutlined, - RightOutlined, - DownOutlined, -} from "@ant-design/icons"; +import { LuFolder as FolderOutlined, LuFolderOpen as FolderOpenOutlined, LuChevronRight as RightOutlined, LuChevronDown as DownOutlined } from "react-icons/lu"; import { Avatar } from "antd"; import type { ICellRendererParams } from "ag-grid-community"; import { useStyles } from "../edit-groups-modal.styles"; import type { ITableRow } from "../types"; -import type { ApiProduct, ApiVariant } from "@/graphql/types"; +import { BundleItemType, type ApiProduct, type ApiVariant } from "@/graphql/types"; export interface INameCellRendererParams extends ICellRendererParams { @@ -27,11 +22,11 @@ const getVariantImageUrl = (variant?: ApiVariant): string | null => { // Helper to get image URL from product const getProductImageUrl = (product?: ApiProduct): string | null => { if (!product) return null; - // Try featured image first - if (product.featuredImage?.url) return product.featuredImage.url; - // Fallback to first media item - const mediaItem = product.media?.edges?.[0]?.node; - return mediaItem?.file?.url ?? null; + + const variant = product.variants.edges.find((edge) => edge.node.isDefault)?.node + ?? product.variants.edges[0]?.node; + + return getVariantImageUrl(variant); }; export const NameCellRenderer = (params: INameCellRendererParams) => { @@ -78,11 +73,14 @@ export const NameCellRenderer = (params: INameCellRendererParams) => { } // Render item row (product or variant) - if (data.itemType === "VARIANT") { + if (data.itemType === BundleItemType.Variant) { const variant = data.assignedVariant; const productTitle = variant?.product?.title; const variantTitle = - data.title || variant?.title || variant?.sku || "Unknown Variant"; + data.title || + variant?.title || + variant?.inventoryItem?.sku || + "Unknown Variant"; const imageUrl = getVariantImageUrl(variant); return ( diff --git a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/price-rule-cell-renderer.tsx b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/price-rule-cell-renderer.tsx similarity index 91% rename from admin/src/domains/promos/bundles/modals/edit-groups-modal/components/price-rule-cell-renderer.tsx rename to admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/price-rule-cell-renderer.tsx index 092062fe4..1412edf45 100644 --- a/admin/src/domains/promos/bundles/modals/edit-groups-modal/components/price-rule-cell-renderer.tsx +++ b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/components/price-rule-cell-renderer.tsx @@ -1,11 +1,13 @@ import { useState, useMemo, useRef } from "react"; import { Dropdown } from "antd"; -import { DownOutlined } from "@ant-design/icons"; +import { LuChevronDown as DownOutlined } from "react-icons/lu"; import type { MenuProps } from "antd"; import type { ICellRendererParams } from "ag-grid-community"; import type { ITableRow } from "../types"; import type { PricingRuleTemplate, BundleItem } from "../../../types"; -import { BundlePriceType, PRICE_RULE_OPTIONS } from "../../../types"; +import { BundlePriceType } from "@/graphql/types"; +import { PRICE_RULE_OPTIONS } from "../../../types"; +import { Dash } from "@/shared/components/editor-grid"; // Helper to determine if pricingRule is a template const isTemplate = ( @@ -102,7 +104,7 @@ export const PriceRuleCellRenderer = ({ onOpenChange={(visible) => { if (!visible) setOpen(false); }} - dropdownRender={(menu) => ( + popupRender={(menu) => (
    {menu}
    )} > @@ -132,13 +134,13 @@ export const PriceValueCellRenderer = ({ if (!data || data.type === "group") return null; const rule = data.pricingRule; - if (!rule) return "—"; + if (!rule) return ; const priceType = isTemplate(rule) ? rule.priceType : rule.priceType; const priceValue = isTemplate(rule) ? rule.priceValue : rule.priceValue; const option = PRICE_RULE_OPTIONS.find((r) => r.value === priceType); - if (!option?.requiresValue) return "—"; - if (priceValue == null) return "—"; + if (!option?.requiresValue) return ; + if (priceValue == null) return ; return `${priceValue}${option.valueSuffix || ""}`; }; diff --git a/admin/src/domains/promos/bundles/modals/edit-groups-modal/edit-groups-modal.styles.ts b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/edit-groups-modal.styles.ts similarity index 100% rename from admin/src/domains/promos/bundles/modals/edit-groups-modal/edit-groups-modal.styles.ts rename to admin/src/domains/inventory/bundles/modals/edit-groups-modal/edit-groups-modal.styles.ts diff --git a/admin/src/domains/inventory/bundles/modals/edit-groups-modal/index.tsx b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/index.tsx new file mode 100644 index 000000000..f13b56593 --- /dev/null +++ b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/index.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { Button, Divider, Dropdown } from "antd"; +import { LuPlus as PlusOutlined, LuFolder as FolderOutlined } from "react-icons/lu"; +import { useModalStackContext, ModalLayout } from "@/layouts/modals"; + +import { useStyles } from "./edit-groups-modal.styles"; +import { BundleGroupsGrid, rowsToGroups } from "./components"; +import type { BundleGroupsGridHandle } from "./components/bundle-groups-grid"; +import type { IBundleGroup, PricingRuleTemplate } from "../../types"; + +// ============================================================================ +// Payload +// ============================================================================ + +export interface IEditGroupsModalPayload { + groups: IBundleGroup[]; + pricingTemplates: PricingRuleTemplate[]; + onSave?: (groups: IBundleGroup[]) => void; +} + +// ============================================================================ +// Component +// ============================================================================ + +export const EditGroupsModal = () => { + const { styles } = useStyles(); + const { pop, setDirty, payload } = useModalStackContext(); + + const modalPayload = payload as unknown as IEditGroupsModalPayload | undefined; + const pricingTemplates = modalPayload?.pricingTemplates ?? []; + + // Grid ref for accessing methods + const gridRef = useRef(null); + + // Handle rows change + const handleRowsChange = useCallback(() => { + setDirty(true); + }, [setDirty]); + + // Handle save + const handleSave = useCallback(() => { + const rows = gridRef.current?.getRows() ?? []; + const groups = rowsToGroups(rows); + modalPayload?.onSave?.(groups); + pop(); + }, [modalPayload, pop]); + + // Header extra with Add button + const headerExtra = ( +
    + , + onClick: () => gridRef.current?.addGroup(), + }, + ], + }} + trigger={["click"]} + > + + + +
    + ); + + return ( + +
    +
    + +
    +
    +
    + ); +}; + +export default EditGroupsModal; diff --git a/admin/src/domains/inventory/bundles/modals/edit-groups-modal/types.ts b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/types.ts new file mode 100644 index 000000000..d4364e178 --- /dev/null +++ b/admin/src/domains/inventory/bundles/modals/edit-groups-modal/types.ts @@ -0,0 +1,51 @@ +import type { + ApiFile, + ApiProduct, + ApiVariant, + BundleItemType, + BundlePriceType, +} from "@/graphql/types"; +import type { PricingRuleTemplate } from "../../types"; + +// ============================================================================ +// Row Types +// ============================================================================ + +export type RowType = "group" | "item"; + +// ============================================================================ +// Table Row +// ============================================================================ + +export interface ITableRow { + id: string; + type: RowType; + name: string; + parentId: string | null; + sortIndex: number; + level: number; // 0 = group, 1 = item + + // Group-specific fields + minSelection?: number | null; + maxSelection?: number | null; + + // Item-specific fields + itemType?: BundleItemType; + assignedProduct?: ApiProduct; + assignedVariant?: ApiVariant; + excludeAssignedProductVariants?: string[] | null; + title?: string | null; + featuredImage?: ApiFile | null; + minQty?: number | null; + maxQty?: number | null; + pricingRule?: PricingRuleTemplate | { + priceType: BundlePriceType; + priceValue: number | null; + }; + + // Visible field + visible?: "yes" | "no"; + + // Selected field (pre-selected by default) + selected?: "yes" | "no"; +} diff --git a/admin/src/domains/promos/bundles/modals/edit-settings-modal/components/index.ts b/admin/src/domains/inventory/bundles/modals/edit-settings-modal/components/index.ts similarity index 100% rename from admin/src/domains/promos/bundles/modals/edit-settings-modal/components/index.ts rename to admin/src/domains/inventory/bundles/modals/edit-settings-modal/components/index.ts diff --git a/admin/src/domains/promos/bundles/modals/edit-settings-modal/components/settings-tab.tsx b/admin/src/domains/inventory/bundles/modals/edit-settings-modal/components/settings-tab.tsx similarity index 98% rename from admin/src/domains/promos/bundles/modals/edit-settings-modal/components/settings-tab.tsx rename to admin/src/domains/inventory/bundles/modals/edit-settings-modal/components/settings-tab.tsx index e9079e537..cce03a758 100644 --- a/admin/src/domains/promos/bundles/modals/edit-settings-modal/components/settings-tab.tsx +++ b/admin/src/domains/inventory/bundles/modals/edit-settings-modal/components/settings-tab.tsx @@ -9,10 +9,10 @@ import { Tooltip, Flex, } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { LuCircleHelp as InfoCircleOutlined } from "react-icons/lu"; import { Paper, PaperHeader } from "@/ui-kit/paper"; -import type { DisplayStyle, OutOfStockBehavior } from "@/domains/promos/bundles/types"; +import type { DisplayStyle, OutOfStockBehavior } from "@/domains/inventory/bundles/types"; // ============================================================================ // Styles diff --git a/admin/src/domains/inventory/bundles/modals/edit-settings-modal/index.tsx b/admin/src/domains/inventory/bundles/modals/edit-settings-modal/index.tsx new file mode 100644 index 000000000..849822bb3 --- /dev/null +++ b/admin/src/domains/inventory/bundles/modals/edit-settings-modal/index.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + useModalStackContext, + ModalLayout, + ModalHeader, +} from "@/layouts/modals"; +import { SettingsTab } from "./components"; +import type { + IBundleSettings, + DisplayStyle, + OutOfStockBehavior, +} from "@/domains/inventory/bundles/types"; + +// ============================================================================ +// Payload +// ============================================================================ + +export interface IEditSettingsModalPayload { + settings: IBundleSettings; + onSave?: (settings: IBundleSettings) => void; +} + +// ============================================================================ +// Default Settings +// ============================================================================ + +const DEFAULT_BUNDLE_SETTINGS: IBundleSettings = { + displayStyle: "accordion", + showImages: true, + showSku: true, + showStock: true, + showComparePrice: false, + outOfStockBehavior: "disable", + inheritStock: true, + validationMessage: null, +}; + +// ============================================================================ +// Component +// ============================================================================ + +export const EditSettingsModal = () => { + const { pop, setDirty, payload } = useModalStackContext(); + + const modalPayload = payload as unknown as IEditSettingsModalPayload | undefined; + + const [settings, setSettings] = useState( + modalPayload?.settings ?? DEFAULT_BUNDLE_SETTINGS + ); + + const updateSetting = useCallback( + (key: K, value: IBundleSettings[K]) => { + setSettings((prev) => ({ ...prev, [key]: value })); + setDirty(true); + }, + [setDirty] + ); + + const handleSave = useCallback(() => { + modalPayload?.onSave?.(settings); + pop(); + }, [settings, modalPayload, pop]); + + return ( + + } + > + updateSetting("displayStyle", style)} + showImages={settings.showImages} + onShowImagesChange={(value: boolean) => updateSetting("showImages", value)} + showSku={settings.showSku} + onShowSkuChange={(value: boolean) => updateSetting("showSku", value)} + showStock={settings.showStock} + onShowStockChange={(value: boolean) => updateSetting("showStock", value)} + showComparePrice={settings.showComparePrice} + onShowComparePriceChange={(value: boolean) => updateSetting("showComparePrice", value)} + outOfStockBehavior={settings.outOfStockBehavior} + onOutOfStockBehaviorChange={(value: OutOfStockBehavior) => updateSetting("outOfStockBehavior", value)} + inheritStock={settings.inheritStock} + onInheritStockChange={(value: boolean) => updateSetting("inheritStock", value)} + /> + + ); +}; + +export default EditSettingsModal; diff --git a/admin/src/domains/promos/bundles/modals/edit-templates-modal/components/index.ts b/admin/src/domains/inventory/bundles/modals/edit-templates-modal/components/index.ts similarity index 100% rename from admin/src/domains/promos/bundles/modals/edit-templates-modal/components/index.ts rename to admin/src/domains/inventory/bundles/modals/edit-templates-modal/components/index.ts diff --git a/admin/src/domains/promos/bundles/modals/edit-templates-modal/components/templates-tab.tsx b/admin/src/domains/inventory/bundles/modals/edit-templates-modal/components/templates-tab.tsx similarity index 95% rename from admin/src/domains/promos/bundles/modals/edit-templates-modal/components/templates-tab.tsx rename to admin/src/domains/inventory/bundles/modals/edit-templates-modal/components/templates-tab.tsx index d65d372ef..8f95a5204 100644 --- a/admin/src/domains/promos/bundles/modals/edit-templates-modal/components/templates-tab.tsx +++ b/admin/src/domains/inventory/bundles/modals/edit-templates-modal/components/templates-tab.tsx @@ -15,22 +15,15 @@ import { Empty, Tag, } from "antd"; -import { - PlusOutlined, - DeleteOutlined, - EditOutlined, - SaveOutlined, - CloseOutlined, - InfoCircleOutlined, -} from "@ant-design/icons"; +import { LuPlus as PlusOutlined, LuTrash2 as DeleteOutlined, LuPencil as EditOutlined, LuSave as SaveOutlined, LuX as CloseOutlined, LuCircleHelp as InfoCircleOutlined } from "react-icons/lu"; import type { ColumnsType } from "antd/es/table"; import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { BundlePriceType } from "@/graphql/types"; import { - BundlePriceType, type PricingRuleTemplate, PRICE_RULE_OPTIONS, -} from "@/domains/promos/bundles/types"; +} from "@/domains/inventory/bundles/types"; // ============================================================================ // Styles @@ -99,7 +92,7 @@ export const TemplatesTab = ({ const newTemplate: IEditingTemplate = { id: `tpl-${Date.now()}`, name: "", - priceType: BundlePriceType.DISCOUNT_PERCENT, + priceType: BundlePriceType.DiscountPercent, priceValue: 10, isNew: true, }; @@ -183,8 +176,8 @@ export const TemplatesTab = ({ ...editingTemplate, priceType: value, priceValue: - value === BundlePriceType.FREE || - value === BundlePriceType.BASE + value === BundlePriceType.Free || + value === BundlePriceType.Base ? null : editingTemplate.priceValue ?? 10, }) diff --git a/admin/src/domains/inventory/bundles/modals/edit-templates-modal/index.tsx b/admin/src/domains/inventory/bundles/modals/edit-templates-modal/index.tsx new file mode 100644 index 000000000..12fd50b3e --- /dev/null +++ b/admin/src/domains/inventory/bundles/modals/edit-templates-modal/index.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + useModalStackContext, + ModalLayout, + ModalHeader, +} from "@/layouts/modals"; +import { TemplatesTab } from "./components"; +import type { + PricingRuleTemplate, +} from "@/domains/inventory/bundles/types"; + +// ============================================================================ +// Payload +// ============================================================================ + +export interface IEditTemplatesModalPayload { + pricingTemplates: PricingRuleTemplate[]; + onSave?: (data: { pricingTemplates: PricingRuleTemplate[] }) => void; +} + +// ============================================================================ +// Component +// ============================================================================ + +export const EditTemplatesModal = () => { + const { pop, setDirty, payload } = useModalStackContext(); + + const modalPayload = payload as unknown as IEditTemplatesModalPayload | undefined; + + const [pricingTemplates, setPricingTemplates] = useState( + modalPayload?.pricingTemplates ?? [] + ); + + const handlePricingTemplatesChange = useCallback( + (templates: PricingRuleTemplate[]) => { + setPricingTemplates(templates); + setDirty(true); + }, + [setDirty] + ); + + const handleSave = useCallback(() => { + modalPayload?.onSave?.({ pricingTemplates }); + pop(); + }, [pricingTemplates, modalPayload, pop]); + + return ( + + } + > + + + ); +}; + +export default EditTemplatesModal; diff --git a/admin/src/domains/inventory/bundles/modals/variant-settings-modal/index.tsx b/admin/src/domains/inventory/bundles/modals/variant-settings-modal/index.tsx new file mode 100644 index 000000000..426aa7d2e --- /dev/null +++ b/admin/src/domains/inventory/bundles/modals/variant-settings-modal/index.tsx @@ -0,0 +1,556 @@ +"use client"; + +import { useState, useMemo, useCallback, useRef } from "react"; +import { createStyles } from "antd-style"; +import { Typography, Tag, Divider, Empty, Switch, Flex } from "antd"; +import { AgGridReact } from "ag-grid-react"; +import { + ColDef, + ModuleRegistry, + AllCommunityModule, + GetRowIdParams, + ICellRendererParams, + SelectionChangedEvent, + RowDragEndEvent, +} from "ag-grid-community"; +import { + useModalStackContext, + ModalLayout, + ModalHeader, +} from "@/layouts/modals"; +import { useAgGridTheme } from "@/hooks"; +import { Dash } from "@/shared/components/editor-grid"; +import { BundlePriceType } from "@/graphql/types"; + +import type { IBundleItemVariantSettingsModalPayload } from "@/domains/inventory/bundles/modals"; + +// Format price helper +const formatPrice = (price: number): string => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(price); +}; + +ModuleRegistry.registerModules([AllCommunityModule]); + +// ============================================================================ +// Types +// ============================================================================ + +type PriceType = BundlePriceType; + +interface IVariantRow { + id: string; + title: string; + sku: string; + price: number; + stock: number; + sortIndex: number; + options?: { optionId: string; value: string }[]; + finalPrice: number; +} + +// ============================================================================ +// Price calculation helpers +// ============================================================================ + +const calculateFinalPrice = ( + basePrice: number, + priceType: PriceType, + priceValue: number | null +): number => { + switch (priceType) { + case BundlePriceType.Base: + return basePrice; + case BundlePriceType.Fixed: + return priceValue ?? 0; + case BundlePriceType.DiscountPercent: + return basePrice * (1 - (priceValue ?? 0) / 100); + case BundlePriceType.DiscountFixed: + return basePrice - (priceValue ?? 0); + case BundlePriceType.Free: + return 0; + default: + return basePrice; + } +}; + +const formatPriceRule = (priceType: PriceType, priceValue: number | null): string | null => { + switch (priceType) { + case BundlePriceType.Base: + return "No change"; + case BundlePriceType.Fixed: + return `Fixed: ${formatPrice(priceValue ?? 0)}`; + case BundlePriceType.DiscountPercent: + return `-${priceValue ?? 0}%`; + case BundlePriceType.DiscountFixed: + return `-${formatPrice(priceValue ?? 0)}`; + case BundlePriceType.Free: + return "Free"; + default: + return null; + } +}; + +// ============================================================================ +// Styles +// ============================================================================ + +const useStyles = createStyles(({ token }) => ({ + content: { + padding: "0 24px 24px", + }, + section: { + marginBottom: 20, + }, + sectionTitle: { + fontSize: 13, + fontWeight: 600, + marginBottom: 12, + textTransform: "uppercase", + letterSpacing: "0.5px", + color: token.colorTextSecondary, + }, + optionGroup: { + marginBottom: 16, + }, + optionGroupTitle: { + fontSize: 12, + fontWeight: 500, + marginBottom: 8, + color: token.colorText, + }, + optionValues: { + display: "flex", + flexWrap: "wrap", + gap: 8, + }, + gridWrapper: { + width: "100%", + height: 300, + "& .ag-header-cell": { + fontSize: 12, + fontWeight: 500, + }, + "& .ag-cell": { + display: "flex", + alignItems: "center", + lineHeight: 1.4, + }, + "& .ag-cell-wrapper": { + width: "100%", + display: "flex", + alignItems: "center", + }, + "& .ag-row-drag": { + cursor: "grab", + color: token.colorTextQuaternary, + "&:hover": { + color: token.colorTextSecondary, + }, + }, + "& .ag-row-dragging": { + cursor: "grabbing", + }, + "& .ec-dash": { + display: "inline-block", + width: 24, + height: 4, + backgroundColor: token.colorBorder, + borderRadius: 2, + verticalAlign: "middle", + }, + }, + sectionHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }, + selectedCount: { + fontSize: 12, + color: token.colorTextSecondary, + }, +})); + +// ============================================================================ +// Cell Renderers +// ============================================================================ + +const BasePriceCellRenderer = ({ data }: ICellRendererParams) => { + if (!data) return null; + return {formatPrice(data.price)}; +}; + +const FinalPriceCellRenderer = ({ data }: ICellRendererParams) => { + if (!data) return null; + return {formatPrice(data.finalPrice)}; +}; + +const PriceRuleCellRenderer = ({ + value, +}: ICellRendererParams) => { + return value == null ? : {String(value)}; +}; + +// ============================================================================ +// Component +// ============================================================================ + +export const VariantSettingsModal = () => { + const { styles } = useStyles(); + const agGridTheme = useAgGridTheme(); + const { pop, payload } = useModalStackContext(); + const gridRef = useRef>(null); + + const modalPayload = payload as IBundleItemVariantSettingsModalPayload | undefined; + + // Extract data from payload + const { + productTitle = "Product", + availableVariantIds: initialVariantIds, + priceType = BundlePriceType.Base, + priceValue = null, + variants = [], + options = [], + showAsVariants: initialShowAsVariants = false, + onSave, + } = modalPayload ?? {}; + + // Local state + const [selectedVariantIds, setSelectedVariantIds] = useState( + () => initialVariantIds ?? variants.map((v) => v.id) + ); + const [showAsVariants, setShowAsVariants] = useState(initialShowAsVariants); + + // Row data for grid (stateful for drag reordering) + const [rowData, setRowData] = useState(() => + variants.map((v, index) => ({ + id: v.id, + title: v.title, + sku: v.sku, + price: v.price, + stock: v.stock, + sortIndex: index, + options: v.options, + finalPrice: calculateFinalPrice(v.price, priceType, priceValue), + })) + ); + + // Get unique option values grouped by option + const optionGroups = useMemo(() => { + if (!options || options.length === 0) return []; + + return options.map((option) => { + // Find which values are available based on selected variants + const selectedVariants = variants.filter((v) => + selectedVariantIds.includes(v.id) + ); + + const availableValues = new Set( + selectedVariants.flatMap( + (v) => + v.options + ?.filter((o) => o.optionId === option.id) + .map((o) => o.value) ?? [] + ) + ); + + return { + id: option.id, + name: option.name, + values: option.values.map((value) => ({ + value, + isSelected: availableValues.has(value), + count: selectedVariants.filter((v) => + v.options?.some( + (o) => o.optionId === option.id && o.value === value + ) + ).length, + })), + }; + }); + }, [options, variants, selectedVariantIds]); + + // Handle selection change from grid + const handleSelectionChanged = useCallback( + (event: SelectionChangedEvent) => { + const selectedNodes = event.api.getSelectedNodes(); + const selectedIds = selectedNodes + .map((node) => node.data?.id) + .filter((id): id is string => !!id); + setSelectedVariantIds(selectedIds); + }, + [] + ); + + // Handle row drag end + const handleRowDragEnd = useCallback( + (event: RowDragEndEvent) => { + const { node, overIndex } = event; + if (overIndex === undefined || overIndex === null) return; + + const movedItem = node.data; + if (!movedItem) return; + + setRowData((prev) => { + const newData = [...prev]; + const oldIndex = newData.findIndex((item) => item.id === movedItem.id); + newData.splice(oldIndex, 1); + newData.splice(overIndex, 0, movedItem); + + // Update sort indices + return newData.map((item, index) => ({ + ...item, + sortIndex: index, + })); + }); + }, + [] + ); + + // Toggle by option value + const handleOptionValueToggle = useCallback( + (optionId: string, value: string, checked: boolean) => { + const variantsWithValue = variants.filter((v) => + v.options?.some((o) => o.optionId === optionId && o.value === value) + ); + + if (checked) { + setSelectedVariantIds((prev) => [ + ...new Set([...prev, ...variantsWithValue.map((v) => v.id)]), + ]); + } else { + const variantIdsToRemove = variantsWithValue.map((v) => v.id); + setSelectedVariantIds((prev) => + prev.filter((id) => !variantIdsToRemove.includes(id)) + ); + } + }, + [variants] + ); + + // Save changes + const handleSave = useCallback(() => { + const allVariantIds = variants.map((v) => v.id); + + // Get selected IDs in the order they appear in rowData + const orderedSelectedIds = rowData + .filter((row) => selectedVariantIds.includes(row.id)) + .map((row) => row.id); + + const isAllSelected = + orderedSelectedIds.length === allVariantIds.length && + allVariantIds.every((id) => orderedSelectedIds.includes(id)); + + onSave?.({ + availableVariantIds: isAllSelected ? null : orderedSelectedIds, + showAsVariants, + }); + pop(); + }, [selectedVariantIds, variants, rowData, showAsVariants, onSave, pop]); + + // Price rule label for display + const priceRuleLabel = useMemo( + () => formatPriceRule(priceType, priceValue), + [priceType, priceValue] + ); + + // Column definitions + const columnDefs = useMemo[]>( + () => [ + { + headerName: "", + field: "sortIndex", + width: 50, + rowDrag: true, + suppressMovable: true, + resizable: false, + }, + { + headerName: "Variant", + field: "title", + flex: 1, + minWidth: 120, + headerCheckboxSelection: true, + checkboxSelection: true, + }, + { + headerName: "Base Price", + field: "price", + width: 100, + cellRenderer: BasePriceCellRenderer, + }, + { + headerName: "Rule", + field: "price", + width: 90, + valueGetter: () => priceRuleLabel, + cellRenderer: PriceRuleCellRenderer, + cellStyle: { color: "var(--ant-color-text-secondary)", fontSize: 12 }, + }, + { + headerName: "Final Price", + field: "finalPrice", + width: 100, + cellRenderer: FinalPriceCellRenderer, + }, + ], + [priceRuleLabel] + ); + + const defaultColDef = useMemo( + () => ({ + resizable: false, + sortable: true, + suppressMovable: true, + }), + [] + ); + + const getRowId = useCallback( + (params: GetRowIdParams) => params.data.id, + [] + ); + + // Sync grid selection with state + const onGridReady = useCallback(() => { + if (gridRef.current?.api) { + gridRef.current.api.forEachNode((node) => { + if (node.data && selectedVariantIds.includes(node.data.id)) { + node.setSelected(true); + } + }); + } + }, [selectedVariantIds]); + + // Update grid selection when option filters change + const updateGridSelection = useCallback(() => { + if (gridRef.current?.api) { + gridRef.current.api.forEachNode((node) => { + if (node.data) { + const shouldSelect = selectedVariantIds.includes(node.data.id); + if (node.isSelected() !== shouldSelect) { + node.setSelected(shouldSelect); + } + } + }); + } + }, [selectedVariantIds]); + + // Sync selection when selectedVariantIds changes from option filters + useMemo(() => { + updateGridSelection(); + }, [updateGridSelection]); + + return ( + + } + > +
    + {/* Option Filters */} + {optionGroups.length > 0 && ( +
    + + Filter by Options + + {optionGroups.map((group) => ( +
    + + {group.name} + +
    + {group.values.map(({ value, isSelected, count }) => ( + + handleOptionValueToggle(group.id, value, checked) + } + > + {value} ({count}) + + ))} +
    +
    + ))} +
    + )} + + + + {/* Variant Table */} +
    +
    + + Variants + + + {selectedVariantIds.length} of {variants.length} selected + +
    + + {variants.length === 0 ? ( + + ) : ( +
    + + ref={gridRef} + theme={agGridTheme} + rowData={rowData} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={getRowId} + rowSelection="multiple" + rowMultiSelectWithClick + suppressRowClickSelection={false} + rowDragManaged + animateRows + suppressMovableColumns + onSelectionChanged={handleSelectionChanged} + onRowDragEnd={handleRowDragEnd} + onGridReady={onGridReady} + rowHeight={44} + headerHeight={36} + /> +
    + )} +
    + + + + {/* Show as variants switch */} + +
    + Show as variants +
    + + Display each variant as a separate row in the bundle items table + +
    + +
    +
    +
    + ); +}; + +export default VariantSettingsModal; diff --git a/admin/src/domains/inventory/bundles/page/filter-schema.ts b/admin/src/domains/inventory/bundles/page/filter-schema.ts new file mode 100644 index 000000000..052b0de94 --- /dev/null +++ b/admin/src/domains/inventory/bundles/page/filter-schema.ts @@ -0,0 +1,22 @@ +import { FilterType, enumOperators } from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { BundleType } from "@/graphql/types"; +import { productLikeBaseFilterSchema } from "@/domains/inventory/products/list-page"; + +export const filterSchema: IFilterSchema[] = [ + ...productLikeBaseFilterSchema, + { + key: "bundleType", + label: "Bundle Type", + description: "Filter by bundle type", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "bundleType", + options: [ + { label: "Fixed Kit", value: BundleType.Fixed }, + { label: "Multipack", value: BundleType.Multipack }, + { label: "Mix & Match", value: BundleType.MixAndMatch }, + { label: "Custom", value: BundleType.Custom }, + ], + }, +]; diff --git a/admin/src/domains/inventory/bundles/page/page-config.ts b/admin/src/domains/inventory/bundles/page/page-config.ts new file mode 100644 index 000000000..629cb788e --- /dev/null +++ b/admin/src/domains/inventory/bundles/page/page-config.ts @@ -0,0 +1,67 @@ +import type { + FilterTransformer, + SortFieldMapping, + UsePageConfigReturn, +} from "@/hooks"; +import type { + ApiBundleOrderByInput, + ApiBundleWhereInput, +} from "@/graphql/types"; +import { BundleOrderField } from "@/graphql/types"; +import { + buildProductLikeQueryVariables, + buildProductLikeSearchCondition, + createProductLikeFilterTransformers, + createProductLikeSortFieldMapping, + toProductLikeQueryVariables, +} from "@/domains/inventory/products/list-page"; +import type { BundlesQueryVariables } from "../graphql"; + +export const bundleSortFieldMapping: SortFieldMapping = { + ...createProductLikeSortFieldMapping({ + Name: BundleOrderField.Name, + MinPriceMinor: BundleOrderField.MinPriceMinor, + MaxPriceMinor: BundleOrderField.MaxPriceMinor, + PrimaryCategoryName: BundleOrderField.PrimaryCategoryName, + BrandName: BundleOrderField.BrandName, + }), + type: BundleOrderField.BundleType, +}; + +export const buildBundleSearchCondition = + buildProductLikeSearchCondition; + +export const bundleFilterTransformers: Record< + string, + FilterTransformer +> = { + ...createProductLikeFilterTransformers(), +}; + +export function buildBundlesQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): BundlesQueryVariables { + return buildProductLikeQueryVariables< + BundlesQueryVariables, + ApiBundleWhereInput, + BundleOrderField, + ApiBundleOrderByInput + >(pageConfig); +} + +export function toBundlesQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): BundlesQueryVariables { + return toProductLikeQueryVariables< + BundlesQueryVariables, + ApiBundleWhereInput, + BundleOrderField, + ApiBundleOrderByInput + >(pageConfig); +} diff --git a/admin/src/domains/inventory/bundles/page/page.tsx b/admin/src/domains/inventory/bundles/page/page.tsx new file mode 100644 index 000000000..b9d227be5 --- /dev/null +++ b/admin/src/domains/inventory/bundles/page/page.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useState, useMemo, useRef, useCallback } from "react"; +import { Alert, Typography, Flex, Button, Tag } from "antd"; +import { LuPlus as PlusOutlined, LuPencil as EditOutlined, LuTrash2 as DeleteOutlined, LuGift as GiftOutlined } from "react-icons/lu"; +import { AgGridReact } from "ag-grid-react"; +import { useModalStack } from "@/layouts/modals"; +import { + ColDef, + ModuleRegistry, + AllCommunityModule, + RowSelectionModule, + GridStateModule, + SelectionChangedEvent, +} from "ag-grid-community"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { FloatingPanelStack } from "@/ui-kit/floating-panel-stack"; +import type { ActionConfig } from "@/ui-kit/floating-panel-stack/core/types"; +import type { PanelConfig } from "@/ui-kit/floating-panel-stack/data-page/floating-panel-stack"; +import { + useAgGridTheme, + useAgGridRowSelection, +} from "@/hooks"; +import { useInventoryRelayListPage } from "@/domains/inventory/hooks"; +import { filterSchema } from "./filter-schema"; +import { + buildBundleSearchCondition, + buildBundlesQueryVariables, + bundleFilterTransformers, + bundleSortFieldMapping, +} from "./page-config"; +import { useBundles } from "../hooks"; +import { TableCoverImage } from "@/shared/components/table-cover-image"; +import { Dash } from "@/shared/components/editor-grid"; +import { useDefaultCurrency } from "@/domains/workspace"; +import { formatPrice } from "@/domains/inventory/products/utils/price-formatting"; +import { + BundleOrderField, + BundleType, + type ApiBundle, + type ApiBundleWhereInput, +} from "@/graphql/types"; + +ModuleRegistry.registerModules([ + AllCommunityModule, + RowSelectionModule, + GridStateModule, +]); + +const BundleCellRenderer = ( + props: CustomCellRendererProps, +) => { + const { data } = props; + if (!data) return null; + const thumbnail = data.media[0]?.file; + return ( + + } + /> + {data.title} + + ); +}; + +const TextCellRenderer = ( + props: CustomCellRendererProps & { + testIdSuffix?: string; + }, +) => { + const { data, value, testIdSuffix } = props; + + return ( + + {value ?? } + + ); +}; + +const PriceCellRenderer = ( + props: CustomCellRendererProps & { + currency?: string | null; + testIdSuffix?: string; + }, +) => { + const { data, value, currency, testIdSuffix } = props; + const displayCurrency = data?.priceRange?.currency ?? currency; + + return ( + + {value !== null && value !== undefined && displayCurrency + ? formatPrice(value, displayCurrency) + : } + + ); +}; + +const BUNDLE_TYPE_CONFIG: Record< + BundleType, + { color: string; label: string } +> = { + [BundleType.Fixed]: { color: "blue", label: "Fixed Kit" }, + [BundleType.Multipack]: { color: "cyan", label: "Multipack" }, + [BundleType.MixAndMatch]: { color: "purple", label: "Mix & Match" }, + [BundleType.Custom]: { color: "default", label: "Custom" }, +}; + +const BundleTypeCellRenderer = ( + props: CustomCellRendererProps, +) => { + const { value } = props; + const config = value + ? BUNDLE_TYPE_CONFIG[value as BundleType] + : { color: "default", label: "Custom" }; + return {config.label}; +}; + +const StatusCellRenderer = ( + props: CustomCellRendererProps, +) => { + const { value } = props; + return ( + + {value ? "Published" : "Draft"} + + ); +}; + +const getBundlePrimaryCategoryName = (bundle: ApiBundle): string | null => + bundle.primaryCategory?.name ?? + (bundle.categoryAssignments ?? []).find((assignment) => assignment.isPrimary) + ?.category.name ?? + null; + +const getBundleMinPriceAmount = (bundle: ApiBundle): number | null => + bundle.priceRange?.minPriceAmount ?? null; + +const getBundleMaxPriceAmount = (bundle: ApiBundle): number | null => + bundle.priceRange?.maxPriceAmount ?? null; + +export default function BundlesPage() { + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const [selectedCount, setSelectedCount] = useState(0); + const defaultCurrency = useDefaultCurrency(); + const { push } = useModalStack(); + const { + pageConfig, + items: bundles, + totalCount, + pageInfo, + loading, + error, + handleNextPage, + handlePrevPage, + } = useInventoryRelayListPage< + ApiBundle, + ApiBundleWhereInput, + BundleOrderField, + ReturnType, + ReturnType + >({ + gridRef, + storageKey: "bundles-grid-state", + filterSchema, + sortFieldMapping: bundleSortFieldMapping, + defaultPageSize: 20, + buildSearchCondition: buildBundleSearchCondition, + filterTransformers: bundleFilterTransformers, + buildQueryVariables: buildBundlesQueryVariables, + useListQuery: useBundles, + getItems: (result) => result.bundles, + }); + + const { rowSelection, selectionColumnDef, onCellClicked } = + useAgGridRowSelection({ + onRowAction: (data) => push("bundle", { entityId: data.id, level: 1 }), + }); + + const handleSelectionChanged = useCallback( + (event: SelectionChangedEvent) => { + const selectedRows = event.api.getSelectedRows(); + setSelectedCount(selectedRows.length); + }, + [], + ); + + const deselectAll = useCallback(() => { + gridRef.current?.api.deselectAll(); + setSelectedCount(0); + }, []); + + const handleBulkEdit = useCallback(() => { + const selectedRows = gridRef.current?.api.getSelectedRows() || []; + const ids = selectedRows.map((r) => r.id); + push("bulk-editor", { productIds: ids }); + }, [push]); + + const handleDeleteSelected = useCallback(() => { + const selectedRows = gridRef.current?.api.getSelectedRows() || []; + console.log( + "Delete bundles:", + selectedRows.map((r) => r.id), + ); + deselectAll(); + }, [deselectAll]); + + const selectionActions = useMemo( + () => [ + { + key: "bulk-edit", + label: "Bulk Edit", + icon: , + onClick: handleBulkEdit, + }, + { + key: "delete", + label: "Delete", + icon: , + danger: true, + onClick: handleDeleteSelected, + }, + ], + [handleBulkEdit, handleDeleteSelected], + ); + + const panels = useMemo(() => { + const result: PanelConfig[] = []; + if (selectedCount > 0) { + result.push({ + type: "selection", + count: selectedCount, + actions: selectionActions, + onDeselectAll: deselectAll, + }); + } + return result; + }, [selectedCount, selectionActions, deselectAll]); + + const columnDefs = useMemo[]>( + () => [ + { + headerName: "Bundle", + field: "title", + cellRenderer: BundleCellRenderer, + flex: 2, + minWidth: 280, + }, + { + headerName: "Min price", + colId: "minPriceMinor", + valueGetter: ({ data }) => + data ? getBundleMinPriceAmount(data) : null, + cellRenderer: PriceCellRenderer, + cellRendererParams: { + currency: defaultCurrency, + testIdSuffix: "min-price", + }, + minWidth: 130, + }, + { + headerName: "Max price", + colId: "maxPriceMinor", + valueGetter: ({ data }) => + data ? getBundleMaxPriceAmount(data) : null, + cellRenderer: PriceCellRenderer, + cellRendererParams: { + currency: defaultCurrency, + testIdSuffix: "max-price", + }, + minWidth: 130, + }, + { + headerName: "Category", + colId: "primaryCategoryName", + valueGetter: ({ data }) => + data ? getBundlePrimaryCategoryName(data) : null, + cellRenderer: TextCellRenderer, + cellRendererParams: { testIdSuffix: "category" }, + minWidth: 180, + }, + { + headerName: "Type", + field: "type", + cellRenderer: BundleTypeCellRenderer, + minWidth: 140, + }, + { + headerName: "Status", + field: "isPublished", + cellRenderer: StatusCellRenderer, + minWidth: 120, + resizable: false, + sortable: false, + }, + ], + [defaultCurrency], + ); + + const defaultColDef = useMemo( + () => ({ + resizable: true, + sortable: true, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }), + [], + ); + + const handleCreate = useCallback(() => { + push("bundle", { entityId: "", level: 1 }); + }, [push]); + + return ( + } onClick={handleCreate}> + Create + + } + > + + } + /> + +
    + {error && ( + + )} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={bundles} + loading={loading} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={52} + rowSelection={rowSelection} + selectionColumnDef={selectionColumnDef} + suppressCellFocus + suppressMovableColumns + onCellClicked={onCellClicked} + onSelectionChanged={handleSelectionChanged} + onSortChanged={pageConfig.onSortChanged} + rowStyle={{ cursor: "pointer" }} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + /> +
    + + +
    + + +
    + ); +} diff --git a/admin/src/domains/inventory/bundles/register.tsx b/admin/src/domains/inventory/bundles/register.tsx new file mode 100644 index 000000000..39a1cbca7 --- /dev/null +++ b/admin/src/domains/inventory/bundles/register.tsx @@ -0,0 +1,41 @@ +import { registerModule } from "@/registry"; +import { LuGift as GiftOutlined } from "react-icons/lu"; +import dynamic from "next/dynamic"; + +registerModule({ + key: "merchandising", + domain: "store", + sidebar: { + label: "Merchandising", + icon: , + order: 3, + }, + items: [ + { + key: "badges-list", + path: "/:orgName/:storeName/badges", + disabled: true, + sidebar: { + label: "Badges", + icon: null, + order: 1, + }, + component: dynamic( + () => import("@/domains/inventory/badges/page/page"), + ), + }, + { + key: "featured-products-list", + path: "/:orgName/:storeName/featured-products", + disabled: true, + sidebar: { + label: "Featured products", + icon: null, + order: 2, + }, + component: dynamic( + () => import("@/domains/inventory/featured-products/page/page"), + ), + }, + ], +}); diff --git a/admin/src/domains/inventory/bundles/types.ts b/admin/src/domains/inventory/bundles/types.ts new file mode 100644 index 000000000..1f2df0732 --- /dev/null +++ b/admin/src/domains/inventory/bundles/types.ts @@ -0,0 +1,216 @@ +import type { ApiFile, ApiProduct, ApiVariant } from "@/graphql/types"; +import { BundleItemType, BundlePriceType } from "@/graphql/types"; +import type { IDependencyRule } from "./dependency-rules/types"; + +/** + * Display style for storefront + */ +export type DisplayStyle = "accordion" | "tabs" | "flat" | "wizard"; + +/** + * Behavior when component is out of stock + */ +export type OutOfStockBehavior = "hide" | "disable" | "backorder"; + +/** + * Stock status for bundle items + */ +export type StockStatus = "inStock" | "lowStock" | "outOfStock"; + +// ============================================================================ +// Bundle Item +// ============================================================================ + +export interface BundleItem { + /** Unique identifier for this item */ + id: string; + + /** Bundle item type - determines how the item is displayed */ + itemType: BundleItemType; + + /** Sort index for display order */ + sortIndex: number; + + /** Assigned product (PRODUCT only) */ + assignedProduct?: ApiProduct; + + /** Custom title for this variant (overrides product title) */ + title: string | null; + + /** Custom featured image for this variant (overrides product image) */ + featuredImage: ApiFile | null; + + /** Exclude variant IDs (for PRODUCT) - null = all variants are included */ + excludeAssignedProductVariants?: string[] | null; + + /** Assigned variant (VARIANT only). Variant has product field for product reference. */ + assignedVariant?: ApiVariant; + + /** Minimum quantity for this item (null = 1) */ + minQty: number | null; + + /** Maximum quantity for this item (null = no limit) */ + maxQty: number | null; + + /** Pricing rule for this variant (overrides product pricing) */ + pricingRule: + | PricingRuleTemplate + | { + /** Pricing configuration - applies to all variants */ + priceType: BundlePriceType; + priceValue: number | null; + /** Template if using a pricing template */ + }; + + /** Whether this item is visible on the storefront (default: yes) */ + visible?: "yes" | "no"; + + /** Whether this item is pre-selected by default (default: no) */ + selected?: "yes" | "no"; +} + +// ============================================================================ +// Bundle Group +// ============================================================================ + +export interface IBundleGroup { + /** Unique identifier for this group */ + id: string; + + /** Title for this group */ + title: string; + + /** Sort index for display order */ + sortIndex: number; + + /** Minimum number of items to select (null = no limit) */ + minSelection: number | null; + + /** Maximum number of items to select (null = no limit) */ + maxSelection: number | null; + + /** Items in this group */ + items: BundleItem[]; +} + +// ============================================================================ +// Bundle Configuration +// ============================================================================ + +export interface IBundleConfiguration { + id: string; + title: string; + bundleItems: IBundleGroup[]; + dependencyRules: IDependencyRule[]; +} + +// ============================================================================ +// Pricing Configuration +// ============================================================================ + +export interface PricingRuleTemplate { + id: string; + name: string; + priceType: BundlePriceType; + priceValue: number | null; +} + + +// ============================================================================ +// Bundle Settings +// ============================================================================ + +export interface BundleDisplaySettings { + displayStyle: DisplayStyle; + showImages: boolean; + showSku: boolean; + showStock: boolean; + showComparePrice: boolean; +} + +export interface BundleStockSettings { + outOfStockBehavior: OutOfStockBehavior; + inheritStock: boolean; +} + +export interface IBundleSettings + extends BundleDisplaySettings, + BundleStockSettings { + validationMessage: string | null; +} + +// ============================================================================ +// Tab Types +// ============================================================================ + +export type EditBundleItemsTabKey = + | "groups" + | "pricing" + | "preview" + | "settings"; + +// ============================================================================ +// Helper Types & Constants +// ============================================================================ + +export interface PriceRuleOption { + value: BundlePriceType; + label: string; + requiresValue?: boolean; + valueSuffix?: string; +} + +export const PRICE_RULE_OPTIONS: PriceRuleOption[] = [ + { value: BundlePriceType.Base, label: "No change" }, + { + value: BundlePriceType.Fixed, + label: "Fixed price", + requiresValue: true, + valueSuffix: "$", + }, + { + value: BundlePriceType.DiscountPercent, + label: "Discount %", + requiresValue: true, + valueSuffix: "%", + }, + { + value: BundlePriceType.DiscountFixed, + label: "Discount $", + requiresValue: true, + valueSuffix: "$", + }, + { + value: BundlePriceType.Free, + label: "Free", + }, +]; + +export const ITEM_TYPE_LABELS: Record = { + [BundleItemType.Product]: "Product", + [BundleItemType.Variant]: "Variant", +}; + +export const STOCK_STATUS_LABELS: Record = { + inStock: "In Stock", + lowStock: "Low Stock", + outOfStock: "Out of Stock", +}; + +// ============================================================================ +// Dependency Rules (re-exported from dedicated module) +// ============================================================================ + +export { + DependencyActionType, + DependencyTargetType, + ACTION_TYPE_LABELS, + TARGET_TYPE_LABELS, + ACTIONS_BY_TARGET, +} from "./dependency-rules"; + +export type { + IDependencyCondition, + IDependencyAction, + IDependencyRule, +} from "./dependency-rules"; diff --git a/admin/src/domains/inventory/campaigns/page/page.tsx b/admin/src/domains/inventory/campaigns/page/page.tsx new file mode 100644 index 000000000..c2b29131a --- /dev/null +++ b/admin/src/domains/inventory/campaigns/page/page.tsx @@ -0,0 +1,5 @@ +import { EmptySectionPage } from "@/shared/components/empty-section-page"; + +export default function CampaignsPage() { + return ; +} diff --git a/admin/src/domains/inventory/categories/components/category-content-tabs/category-content-tabs.tsx b/admin/src/domains/inventory/categories/components/category-content-tabs/category-content-tabs.tsx index 31dd60d51..1b87d4fe7 100644 --- a/admin/src/domains/inventory/categories/components/category-content-tabs/category-content-tabs.tsx +++ b/admin/src/domains/inventory/categories/components/category-content-tabs/category-content-tabs.tsx @@ -1,91 +1,17 @@ "use client"; -import { createStyles } from "antd-style"; -import { Button, Typography, Tabs, Dropdown, Flex } from "antd"; -import { WarningOutlined, MoreOutlined } from "@ant-design/icons"; -import { AIButton } from "@/ui-kit/ai-button"; -import { Paper } from "@/ui-kit/paper"; -import type { ICategoryDetail } from "../category-details-card/types"; - -// ============================================================================ -// Styles -// ============================================================================ - -const useStyles = createStyles(({ token }) => ({ - tabsSection: { - minHeight: 120, - }, - contentText: { - "&&": { - margin: 0, - fontSize: 13, - color: token.colorText, - lineHeight: 1.6, - }, - }, - renderedContent: { - fontSize: 13, - color: token.colorText, - lineHeight: 1.6, - minHeight: 80, - maxHeight: 120, - overflow: "hidden", - position: "relative" as const, - "& p": { - margin: "0 0 8px 0", - "&:last-child": { - marginBottom: 0, - }, - }, - "& h1, & h2, & h3, & h4, & h5, & h6": { - margin: "12px 0 8px 0", - fontWeight: 600, - "&:first-child": { - marginTop: 0, - }, - }, - "& h3": { - fontSize: 14, - }, - "& ul, & ol": { - margin: "8px 0", - paddingLeft: 20, - }, - "& li": { - marginBottom: 4, - }, - "&::after": { - content: '""', - position: "absolute" as const, - bottom: 0, - left: 0, - right: 0, - height: 40, - background: `linear-gradient(transparent, ${token.colorBgContainer})`, - pointerEvents: "none" as const, - }, - }, - emptyContainer: { - padding: "16px 0", - }, - emptyIcon: { - color: token.colorTextQuaternary, - }, - emptyText: { - fontSize: 12, - }, - addButton: { - padding: 0, - height: "auto", - }, -})); +import { Button, Dropdown, Flex } from "antd"; +import { LuEllipsis as MoreOutlined } from "react-icons/lu"; +import { EntityContentTabs } from "@/domains/inventory/components/entity-details-sections"; +import type { ApiCategory } from "@/graphql/types"; // ============================================================================ // Types // ============================================================================ interface ICategoryContentTabsProps { - category: ICategoryDetail; + category: ApiCategory; + onEdit?: () => void; } // ============================================================================ @@ -94,99 +20,50 @@ interface ICategoryContentTabsProps { export const CategoryContentTabs = ({ category, + onEdit, }: ICategoryContentTabsProps) => { - const { styles } = useStyles(); - - const handleWriteWithAI = () => { - console.log("AI writer for category:", category.id); - }; - - const handleEditDescription = () => { - console.log("Edit description for category:", category.id); - }; - - const descriptionHtml = category.description - ? `

    ${category.description}

    ` - : null; - const excerptHtml = category.excerpt ? `

    ${category.excerpt}

    ` : null; + const descriptionHtml = category.description?.html ?? null; + const excerptHtml = category.excerpt?.html ?? null; return ( - - - - - - - ), - }, - { - key: "excerpt", - label: "Excerpt", - children: excerptHtml ? ( -
    - ) : ( - - - - No excerpt added - - - - ), - }, - ]} - /> - + + Edit content, + onClick: onEdit, + }, + ], + }} + trigger={["click"]} + > + + + + Add Subcategories + + )} diff --git a/admin/src/domains/inventory/categories/components/category-details-card/sections/index.ts b/admin/src/domains/inventory/categories/components/category-details-card/sections/index.ts index 7fba703e8..4a27af7aa 100644 --- a/admin/src/domains/inventory/categories/components/category-details-card/sections/index.ts +++ b/admin/src/domains/inventory/categories/components/category-details-card/sections/index.ts @@ -1,4 +1,4 @@ export { HierarchySection } from "./hierarchy-section"; export { ProductsSection } from "./products-section"; export { MediaSection } from "./media-section"; -export { TagsSection } from "./tags-section"; +export { SeoSection } from "./seo-section"; diff --git a/admin/src/domains/inventory/categories/components/category-details-card/sections/media-section.tsx b/admin/src/domains/inventory/categories/components/category-details-card/sections/media-section.tsx index 449fbcf74..f8604c001 100644 --- a/admin/src/domains/inventory/categories/components/category-details-card/sections/media-section.tsx +++ b/admin/src/domains/inventory/categories/components/category-details-card/sections/media-section.tsx @@ -1,11 +1,12 @@ "use client"; import { Image, Flex } from "antd"; -import { EyeOutlined, PlusOutlined } from "@ant-design/icons"; +import { LuEye as EyeOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; import { Paper, PaperHeader } from "@/ui-kit/paper"; import { FeaturedBadge } from "@/ui-kit/featured-badge"; import { MediaPreview, useMediaPreview } from "@/domains/media/components/media-preview"; import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { EntityMediaEmptyState } from "@/domains/inventory/components/entity-details-sections"; import { MediaFilePlaceholder } from "@/domains/inventory/products/components/media-file-placeholder"; import { useMediaStyles } from "../category-details-card.styles"; import type { ApiFile } from "@/graphql/types"; @@ -22,21 +23,53 @@ export const MediaSection = ({ gallery, onEdit }: IMediaSectionProps) => { const showMore = gallery.length > 12; const gallerySlice = gallery.slice(0, showMore ? 11 : 12); const overlayItemsCount = gallerySlice.length + (showMore ? 1 : 0) + 1; + const hasMedia = gallery.length > 0; return ( - + } + actions={ + + } /> -
    - {gallerySlice.map((media, index) => - index === 0 ? ( -
    + {hasMedia ? ( +
    + {gallerySlice.map((media, index) => + index === 0 ? ( +
    + {media.altText + + Preview + + ), + }} + onClick={() => mediaPreview.open(index)} + /> + +
    + ) : ( {media.altText { }} onClick={() => mediaPreview.open(index)} /> - -
    - ) : ( - {media.altText - - Preview - - ), + ) + )} + {showMore && ( + mediaPreview.open(11)} + > + +{gallery.length - 11} + + )} +
    +
    { + if (e.key === "Enter" || e.key === " ") { + onEdit(); + } }} - onClick={() => mediaPreview.open(index)} - /> - ) - )} - {showMore && ( - mediaPreview.open(11)} - > - +{gallery.length - 11} - - )} -
    -
    { - if (e.key === "Enter" || e.key === " ") { - onEdit(); - } - }} - > - + > + +
    +
    +
    + {Array.from({ length: overlayItemsCount }).map((_, idx) => ( +
    + ))} + {Array.from({ length: Math.max(0, 13 - overlayItemsCount) }).map((_, idx) => ( + + ))}
    -
    - {Array.from({ length: overlayItemsCount }).map((_, idx) => ( -
    - ))} - {Array.from({ length: Math.max(0, 13 - overlayItemsCount) }).map((_, idx) => ( - - ))} + ) : ( +
    +
    -
    + )} - new Intl.NumberFormat("ru-RU", { - style: "currency", - currency: "RUB", - }).format(price / 100); - -const getStockConfig = (inStock: boolean) => { - if (inStock) { - return { - color: "var(--ant-color-success)", - icon: "\u25CF", - label: "In Stock", - }; - } - return { - color: "var(--ant-color-error)", - icon: "\u2715", - label: "Out of Stock", - }; +import { TableCoverImage } from "@/shared/components/table-cover-image"; + +const getProductImageUrl = (product: CategoryProductListItem): string | null => { + const firstMedia = [...product.media].sort((a, b) => a.sortIndex - b.sortIndex)[0]; + return firstMedia?.file.url ?? null; }; -const getStatusLabel = (status: EntityStatus) => { - switch (status) { - case EntityStatus.PUBLISHED: - return "Active"; - case EntityStatus.DRAFT: - return "Draft"; - case EntityStatus.ARCHIVED: - return "Archived"; +const formatProductSort = (value: ProductSortBy): string => { + switch (value) { + case ProductSortBy.Manual: + return "Manual"; + case ProductSortBy.Name: + return "Name"; + case ProductSortBy.Newest: + return "Newest"; + case ProductSortBy.Price: + return "Price"; default: - return status; + return value; } }; -// ============================================================================ -// ProductRow -// ============================================================================ +const formatSortDirection = (value: SortDirection): string => + value === SortDirection.Desc ? "Descending" : "Ascending"; -interface IProductRowProps { - product: ICategoryProduct; +interface ProductRowProps { + product: CategoryProductListItem; + isRemoving?: boolean; + onRemove?: (product: CategoryProductListItem) => void; } -const ProductRow = ({ product }: IProductRowProps) => { +const ProductRow = ({ product, isRemoving, onRemove }: ProductRowProps) => { const { styles } = useProductsStyles(); - const stock = getStockConfig(product.inStock); + const imageUrl = getProductImageUrl(product); + const actionItems: MenuProps["items"] = [ + { + key: "unassign", + label: ( + Unassign + ), + onClick: () => onRemove?.(product), + }, + ]; return ( - + - {product.featured ? ( - {product.title} - ) : ( -
    - )} + } + className={styles.productImage} + /> - + {product.title} - - {product.sku || "\u2014"} - + + {product.isPublished ? "Published" : "Draft"} + - - {formatPrice(product.price)} - - - - - {stock.icon} - - - {stock.label} - - + + + @@ -174,67 +255,52 @@ export const ProductsSection = ({ } /> - {hasProducts ? ( + {error && } + + {loading && !hasProducts ? ( + + ) : hasProducts ? ( <>
    - - + {products.map((product) => ( - + ))}
    ProductSKUPrice Status
    - - - {totalCount} products - - - - - No products assigned to this category yet - - + } + state={{ + title: "No products assigned", + description: "Assign products to make them appear in this category.", + }} + /> )} ); diff --git a/admin/src/domains/inventory/categories/components/category-details-card/sections/seo-section.tsx b/admin/src/domains/inventory/categories/components/category-details-card/sections/seo-section.tsx new file mode 100644 index 000000000..caa3e1cc6 --- /dev/null +++ b/admin/src/domains/inventory/categories/components/category-details-card/sections/seo-section.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Button } from "antd"; +import { EditAction } from "@/domains/inventory/products/components/edit-action"; +import { EntitySeoSection } from "@/domains/inventory/components/entity-details-sections"; +import type { ApiCategory } from "@/graphql/types"; +import { getCategoryRoutePath } from "../../../utils/category-route-path"; + +interface SeoSectionProps { + category: ApiCategory; + onEdit?: () => void; +} + +export const SeoSection = ({ category, onEdit }: SeoSectionProps) => { + const categoryPath = getCategoryRoutePath(category); + + return ( + + {onEdit ? ( + + ) : null} + {category.seo?.ogImage && ( + + )} + + } + /> + ); +}; diff --git a/admin/src/domains/inventory/categories/components/category-details-card/sections/tags-section.tsx b/admin/src/domains/inventory/categories/components/category-details-card/sections/tags-section.tsx deleted file mode 100644 index 04266a027..000000000 --- a/admin/src/domains/inventory/categories/components/category-details-card/sections/tags-section.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Tag, Typography, Flex, Dropdown } from "antd"; -import { PlusOutlined, MoreOutlined } from "@ant-design/icons"; -import { Paper, PaperHeader } from "@/ui-kit/paper"; -import { useTagPicker } from "@/shared/components/entity-picker-modal"; -import type { IPickableEntity } from "@/shared/components/entity-picker-modal"; -import type { ITag } from "@/domains/inventory/products/modals"; - -interface ITagsSectionProps { - tags?: ITag[]; -} - -export const TagsSection = ({ tags: initialTags = [] }: ITagsSectionProps) => { - const [tags, setTags] = useState(initialTags); - - const deleteTag = (id: string) => { - setTags((prev) => prev.filter((tag) => tag.id !== id)); - }; - - const { openPicker } = useTagPicker({ - initialSelection: tags.map((tag) => tag.id), - onConfirm: (entities: IPickableEntity[]) => { - const existingById = new Map(tags.map((t) => [t.id, t])); - const newTags = entities.map((entity) => { - const existing = existingById.get(entity.id); - if (existing) { - return existing; - } - return { - id: entity.id, - title: entity.title, - slug: entity.id, - color: "var(--ant-color-primary)", - } satisfies ITag; - }); - setTags(newTags); - }, - }); - - const hasTags = tags.length > 0; - - return ( - - - {hasTags ? ( - - {tags.map((tag) => ( - deleteTag(tag.id), - }, - ], - }} - > - - - {tag.title} - - - - - ))} - - - - Add Tag - - - - ) : ( - - - - - Add Tag - - - - No tags assigned - - - )} - - ); -}; diff --git a/admin/src/domains/inventory/categories/components/category-details-card/types.ts b/admin/src/domains/inventory/categories/components/category-details-card/types.ts index 87737451e..01b7d4c43 100644 --- a/admin/src/domains/inventory/categories/components/category-details-card/types.ts +++ b/admin/src/domains/inventory/categories/components/category-details-card/types.ts @@ -1,90 +1,16 @@ -import type { IMediaFile, EntityStatus } from "@/mocks/products/types"; -import type { ITag } from "@/domains/inventory/products/modals"; -import type { ApiFile } from "@/graphql/types"; - -// ============================================================================ -// Category Detail Types -// ============================================================================ - -export interface ICategoryParent { - id: string; - title: string; - slug: string; -} - -export interface ICategoryChild { - id: string; - title: string; - slug: string; - status: EntityStatus; - productCount: number; - featured: IMediaFile | null; -} - -export interface ICategoryProduct { - id: string; - title: string; - sku: string | null; - price: number; - featured: IMediaFile | null; - status: EntityStatus; - inStock: boolean; -} - -// ============================================================================ -// Category (extended for detail view) -// ============================================================================ - -export interface ICategoryDetail { - id: string; - title: string; - slug: string; - description: string | null; - excerpt: string | null; - status: EntityStatus; - featured: ApiFile | null; - gallery: ApiFile[]; - seoTitle: string | null; - seoDescription: string | null; - createdAt: Date; - updatedAt: Date; -} - -// ============================================================================ -// Mock Data Types -// ============================================================================ - -export interface ICategoryDetailsMockData { - hierarchy: { - ancestors: ICategoryParent[]; - children: ICategoryChild[]; - }; - products: { - items: ICategoryProduct[]; - totalCount: number; - hasNextPage: boolean; - }; - tags: ITag[]; -} - -// ============================================================================ -// Section Type -// ============================================================================ +import type { ApiCategory } from "@/graphql/types"; export type CategorySection = - | "info" + | "identity" + | "content" | "hierarchy" | "products" | "media" | "seo" - | "tags"; - -// ============================================================================ -// Component Props -// ============================================================================ + | "sort" + | "status"; -export interface ICategoryDetailsCardProps { - category: ICategoryDetail; - mockData: ICategoryDetailsMockData; - onEditSection?: (section: CategorySection) => void; +export interface CategoryDetailsCardProps { + category: ApiCategory; + onRefetch?: () => Promise; } diff --git a/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.styles.ts b/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.styles.ts index e9bfa76da..7681dbad5 100644 --- a/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.styles.ts +++ b/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.styles.ts @@ -18,4 +18,8 @@ export const useHeaderStyles = createStyles(({ token }) => ({ divider: { marginBlock: token.margin, }, + kpiTile: { + padding: "12px 16px", + background: token.colorBgElevated, + }, })); diff --git a/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.tsx b/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.tsx index 784e3f9ba..e632b39b2 100644 --- a/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.tsx +++ b/admin/src/domains/inventory/categories/components/category-info-header/category-info-header.tsx @@ -3,50 +3,73 @@ import { useState } from "react"; import { Button, - Tag, - Typography, + Divider, Dropdown, - Tooltip, - Popover, Flex, - Divider, + Popover, + Tag, + Tooltip, + Typography, } from "antd"; -import { - CheckOutlined, - MoreOutlined, - LinkOutlined, - EyeOutlined, - ShareAltOutlined, -} from "@ant-design/icons"; -import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { LuCheck as CheckOutlined, LuEye as EyeOutlined, LuLink as LinkOutlined, LuEllipsis as MoreOutlined, LuShare2 as ShareAltOutlined } from "react-icons/lu"; import { CopyableChip } from "@/ui-kit/copyable-chip"; -import { EntityStatus } from "@/mocks/products/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; import { - UserPopoverContent, SharePopoverContent, + UserPopoverContent, } from "@/domains/inventory/products/components/product-info-header/components"; -import { getStatusConfig } from "@/domains/inventory/products/components/product-info-header/utils"; +import { KPITile } from "@/ui-kit/kpi-tile"; +import { PeriodSwitch } from "@/domains/inventory/products/components/period-switch"; +import { + formatCurrency, + formatNumber, + formatPercent, + getStatusConfig, +} from "@/domains/inventory/products/components/product-info-header/utils"; +import { formatDetailDate } from "@/domains/inventory/utils/format-detail-date"; +import { PERIODS, type Period } from "@/domains/inventory/products/utils/periods"; +import type { ApiCategory } from "@/graphql/types"; +import { getCategoryRoutePath } from "../../utils/category-route-path"; import { useHeaderStyles } from "./category-info-header.styles"; -import type { ICategoryDetail } from "../category-details-card/types"; - -// ============================================================================ -// Types -// ============================================================================ -interface ICategoryInfoHeaderProps { - category: ICategoryDetail; +interface CategoryInfoHeaderProps { + category: ApiCategory; + onEditIdentity?: () => void; + onChangeStatus?: () => void; + onArchive?: () => void; + onEditSort?: () => void; } -// ============================================================================ -// Main Component -// ============================================================================ - -export const CategoryInfoHeader = ({ category }: ICategoryInfoHeaderProps) => { +export const CategoryInfoHeader = ({ + category, + onEditIdentity, + onChangeStatus, + onArchive, + onEditSort, +}: CategoryInfoHeaderProps) => { const { styles } = useHeaderStyles(); + const [kpiPeriod, setKpiPeriod] = useState("7d"); + const [compareEnabled, setCompareEnabled] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false); - const storefrontUrl = `${window.location.origin}/categories/${category.slug}`; + const categoryPath = getCategoryRoutePath(category); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const storefrontUrl = `${origin}/categories/${categoryPath}`; + const statusConfig = getStatusConfig( + category.isPublished ? "published" : "draft", + ); + const statusActionLabel = category.isPublished ? "Unpublish" : "Publish"; + const kpi = { + views: 2847, + viewsTrend: 8, + orders: 156, + ordersTrend: -2, + conversion: 5.5, + conversionTrend: 0.4, + revenue: 48200, + revenueTrend: 12, + }; const handleCopyLink = () => { navigator.clipboard.writeText(window.location.href); @@ -64,12 +87,6 @@ export const CategoryInfoHeader = ({ category }: ICategoryInfoHeaderProps) => { setTimeout(() => setShareCopied(false), 1500); }; - const statusConfig = getStatusConfig(category.status); - - const handleEditTitle = () => { - console.log("Edit category title:", category.title); - }; - const statusTitle = ( @@ -77,43 +94,39 @@ export const CategoryInfoHeader = ({ category }: ICategoryInfoHeaderProps) => { color={statusConfig.color} icon={statusConfig.icon} className={styles.statusTag} + data-testid="category-detail-status" > {statusConfig.label} - {category.status === EntityStatus.PUBLISHED && ( - - {category.updatedAt.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })} - by - - } - placement="bottom" - arrow={false} + + Updated {formatDetailDate(category.updatedAt)} + by + + } + placement="bottom" + arrow={false} + > + - - - )} + Admin + + + ); @@ -160,39 +173,62 @@ export const CategoryInfoHeader = ({ category }: ICategoryInfoHeaderProps) => { Edit identity, + onClick: onEditIdentity, + }, + { + key: "status", + label: {statusActionLabel}, + onClick: onChangeStatus, + }, + { + key: "sort", + label: Edit product sort, + onClick: onEditSort, + }, { type: "divider" as const }, - { key: "duplicate", label: "Duplicate" }, - { key: "export", label: "Export" }, - { type: "divider" as const }, - { key: "archive", label: "Archive", danger: true }, + { + key: "archive", + label: Archive, + danger: true, + onClick: onArchive, + }, ], }} trigger={["click"]} > - } + /> + )} + +
    +
    +
    {getCategoryPath(category)}
    + + {category.name} + + + {totalCount} products + +
    +
    + + + {loading && hasInitialData && } +
    +
    + +
    + {selectedFacetLabels.map(({ key, label, value }) => ( + { + event.preventDefault(); + const input = toFacetInput(value); + if (input) { + removeFacetInput(input); + } + }} + > + {label} + + ))} + {selectedFacetLabels.length > 0 && ( + + )} +
    + +
    + +
    +
    + +
    +
    + + Page {pageIndex} · {items.length} of {totalCount} loaded + +
    + + +
    +
    +
    + + ); +}; diff --git a/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-pagination.tsx b/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-pagination.tsx new file mode 100644 index 000000000..3cb90f462 --- /dev/null +++ b/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-pagination.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button, Typography } from "antd"; +import type { ApiPageInfo } from "@/graphql/types"; +import { useListingPreviewStyles } from "./listing-preview-modal.styles"; + +interface ListingPreviewPaginationProps { + pageInfo: ApiPageInfo | null; + pageIndex: number; + loadedCount: number; + totalCount: number; + loading?: boolean; + onNext: () => void; + onPrevious: () => void; +} + +export const ListingPreviewPagination = ({ + pageInfo, + pageIndex, + loadedCount, + totalCount, + loading, + onNext, + onPrevious, +}: ListingPreviewPaginationProps) => { + const { styles } = useListingPreviewStyles(); + + return ( +
    + + + Page {pageIndex} · {loadedCount} of {totalCount} loaded + + +
    + ); +}; diff --git a/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-product-card.tsx b/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-product-card.tsx new file mode 100644 index 000000000..fa890ee56 --- /dev/null +++ b/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-product-card.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Flex, Tag, Typography } from "antd"; +import { LuImage as PictureOutlined } from "react-icons/lu"; +import type { CategoryListingPreviewItem } from "../../graphql/operation-types"; +import { useListingPreviewStyles } from "./listing-preview-modal.styles"; + +const getFirstImage = (item: CategoryListingPreviewItem) => + [...item.media].sort((a, b) => a.sortIndex - b.sortIndex)[0]?.file ?? null; + +const formatMinorAmount = (amount: string | number, currency: string) => { + const numericAmount = Number(amount); + + if (!Number.isFinite(numericAmount)) { + return null; + } + + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + }).format(numericAmount / 100); +}; + +const formatPrice = (item: CategoryListingPreviewItem) => { + const priceRange = item.priceRange; + + if (!priceRange) { + return "No price"; + } + + const min = formatMinorAmount(priceRange.minPriceAmount, priceRange.currency); + const max = formatMinorAmount(priceRange.maxPriceAmount, priceRange.currency); + + if (!min || !max) { + return "No price"; + } + + return min === max ? min : `${min} - ${max}`; +}; + +interface ListingPreviewProductCardProps { + item: CategoryListingPreviewItem; +} + +export const ListingPreviewProductCard = ({ + item, +}: ListingPreviewProductCardProps) => { + const { styles } = useListingPreviewStyles(); + const image = getFirstImage(item); + + return ( +
    +
    + {image?.url ? ( + {image.altText + ) : ( + + )} +
    +
    + + {item.title} + + {formatPrice(item)} + + Unknown availability + {!item.isPublished && Draft} + +
    +
    + ); +}; diff --git a/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-sort.tsx b/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-sort.tsx new file mode 100644 index 000000000..9ee1b90a1 --- /dev/null +++ b/admin/src/domains/inventory/categories/modals/listing-preview-modal/listing-preview-sort.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Select } from "antd"; +import type { ApiListingOrderByInput } from "@/graphql/types"; +import { ListingSortBy, ListingSortDirection } from "@/graphql/types"; +import type { ListingPreviewSortOption } from "./types"; + +export const LISTING_PREVIEW_SORT_OPTIONS: ListingPreviewSortOption[] = [ + { + key: "newest-desc", + label: "Newest first", + orderBy: { by: ListingSortBy.Newest, direction: ListingSortDirection.Desc }, + }, + { + key: "name-asc", + label: "Name A to Z", + orderBy: { by: ListingSortBy.Name, direction: ListingSortDirection.Asc }, + }, + { + key: "name-desc", + label: "Name Z to A", + orderBy: { by: ListingSortBy.Name, direction: ListingSortDirection.Desc }, + }, + { + key: "price-asc", + label: "Price low to high", + orderBy: { by: ListingSortBy.Price, direction: ListingSortDirection.Asc }, + }, + { + key: "price-desc", + label: "Price high to low", + orderBy: { by: ListingSortBy.Price, direction: ListingSortDirection.Desc }, + }, +]; + +const serializeOrderBy = (value: ApiListingOrderByInput) => + `${value.by}:${value.direction ?? ""}`; + +export const getListingPreviewSortKey = (value: ApiListingOrderByInput) => { + const serialized = serializeOrderBy(value); + return ( + LISTING_PREVIEW_SORT_OPTIONS.find( + (option) => serializeOrderBy(option.orderBy) === serialized, + )?.key ?? "newest-desc" + ); +}; + +interface ListingPreviewSortProps { + value: ApiListingOrderByInput; + onChange: (orderBy: ApiListingOrderByInput) => void; +} + +export const ListingPreviewSort = ({ + value, + onChange, +}: ListingPreviewSortProps) => ( + + )} + /> + {errors.title && ( + + {errors.title.message} + + )} +
    + +
    + + Handle + + ( + + )} + /> + {errors.handle ? ( + + {errors.handle.message} + + ) : ( + + {handleHelpText} + + )} +
    + + + + ); +}; diff --git a/admin/src/domains/inventory/components/entity-edit-forms/entity-seo-modal.tsx b/admin/src/domains/inventory/components/entity-edit-forms/entity-seo-modal.tsx new file mode 100644 index 000000000..dc25a0505 --- /dev/null +++ b/admin/src/domains/inventory/components/entity-edit-forms/entity-seo-modal.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Flex, Input, Typography } from "antd"; +import { ModalHeader, ModalLayout } from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { SeoPreview } from "@/domains/inventory/components/entity-details-sections"; +import { useEntityEditFormStyles } from "./entity-edit-forms.styles"; +import { + OG_DESCRIPTION_MAX, + OG_TITLE_MAX, + SEO_DESCRIPTION_MAX, + SEO_TITLE_MAX, +} from "./constants"; +import { FormField } from "./form-field"; +import { ImageUpload } from "./image-upload"; +import type { + EntityEditSubmitResult, + EntitySeoFormValues, + EntitySeoSubmitHelpers, +} from "./types"; + +interface EntitySeoModalProps { + name: string; + title: string; + initialValues: EntitySeoFormValues; + entityTitle: string; + entitySlug: string; + previewPath: string; + baseUrl?: string; + metaTitleTestId: string; + metaDescriptionTestId: string; + ogTitleTestId: string; + ogDescriptionTestId: string; + onClose: () => void; + onSubmit: ( + values: EntitySeoFormValues, + helpers: EntitySeoSubmitHelpers, + ) => EntityEditSubmitResult; +} + +export const EntitySeoModal = ({ + name, + title, + initialValues, + entityTitle, + entitySlug, + previewPath, + baseUrl, + metaTitleTestId, + metaDescriptionTestId, + ogTitleTestId, + ogDescriptionTestId, + onClose, + onSubmit, +}: EntitySeoModalProps) => { + const { styles } = useEntityEditFormStyles(); + const [submitting, setSubmitting] = useState(false); + + const { + control, + handleSubmit, + setError, + watch, + formState: { errors }, + } = useForm({ + defaultValues: initialValues, + }); + + const values = watch(); + + const submit = async (formValues: EntitySeoFormValues) => { + setSubmitting(true); + + try { + const result = await onSubmit(formValues, { setError }); + + if (result !== false) { + onClose(); + } + } finally { + setSubmitting(false); + } + }; + + return ( + + } + > + + + + + ( + + )} + /> + {errors.seoTitle && ( + + {errors.seoTitle.message} + + )} + + + ( + + )} + /> + {errors.seoDescription && ( + + {errors.seoDescription.message} + + )} + + + + + + Facebook, LinkedIn, etc. + + } + /> + + ( + + )} + /> + {errors.ogTitle && ( + + {errors.ogTitle.message} + + )} + + + ( + + )} + /> + {errors.ogDescription && ( + + {errors.ogDescription.message} + + )} + + + ( + + )} + /> + + + + + + + + + + ); +}; diff --git a/admin/src/domains/inventory/components/entity-edit-forms/form-field.tsx b/admin/src/domains/inventory/components/entity-edit-forms/form-field.tsx new file mode 100644 index 000000000..726f8e341 --- /dev/null +++ b/admin/src/domains/inventory/components/entity-edit-forms/form-field.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { Typography } from "antd"; +import { useEntityEditFormStyles } from "./entity-edit-forms.styles"; + +interface FormFieldProps { + label: string; + children: ReactNode; + isLast?: boolean; +} + +export const FormField = ({ label, children, isLast }: FormFieldProps) => { + const { styles } = useEntityEditFormStyles(); + + return ( +
    + + {label} + + {children} +
    + ); +}; diff --git a/admin/src/domains/inventory/components/entity-edit-forms/image-upload.tsx b/admin/src/domains/inventory/components/entity-edit-forms/image-upload.tsx new file mode 100644 index 000000000..b43d509d9 --- /dev/null +++ b/admin/src/domains/inventory/components/entity-edit-forms/image-upload.tsx @@ -0,0 +1,81 @@ +import { Button, Flex, Image, Typography } from "antd"; +import { LuTrash2 as DeleteOutlined, LuUpload as UploadOutlined } from "react-icons/lu"; +import { useUploadMediaModal } from "@/domains/media/modals"; +import type { ApiFile } from "@/graphql/types"; +import { useEntityEditFormStyles } from "./entity-edit-forms.styles"; + +interface ImageUploadProps { + value: ApiFile | null; + onChange: (file: ApiFile | null) => void; +} + +export const ImageUpload = ({ value, onChange }: ImageUploadProps) => { + const { styles } = useEntityEditFormStyles(); + const { push: openUploadModal } = useUploadMediaModal(); + + const handleUploadClick = () => { + openUploadModal({ + accept: "image/*", + maxFiles: 1, + onUpload: (files: ApiFile[]) => { + if (files.length > 0) { + onChange(files[0]); + } + }, + }); + }; + + const handleRemove = () => { + onChange(null); + }; + + if (value) { + return ( +
    + {value.originalName +
    + ); + } + + return ( +
    { + if (e.key === "Enter" || e.key === " ") { + handleUploadClick(); + } + }} + > + + + + Upload OG Image + + + Recommended: 1200 x 630px + + +
    + ); +}; diff --git a/admin/src/domains/inventory/components/entity-edit-forms/index.ts b/admin/src/domains/inventory/components/entity-edit-forms/index.ts new file mode 100644 index 000000000..d88a2b356 --- /dev/null +++ b/admin/src/domains/inventory/components/entity-edit-forms/index.ts @@ -0,0 +1,10 @@ +export { EntityContentModal } from "./entity-content-modal"; +export { EntityIdentityModal } from "./entity-identity-modal"; +export { EntitySeoModal } from "./entity-seo-modal"; +export { FormField } from "./form-field"; +export { ImageUpload } from "./image-upload"; +export type { + EntityContentFormValues, + EntityIdentityFormValues, + EntitySeoFormValues, +} from "./types"; diff --git a/admin/src/domains/inventory/components/entity-edit-forms/types.ts b/admin/src/domains/inventory/components/entity-edit-forms/types.ts new file mode 100644 index 000000000..611ab8092 --- /dev/null +++ b/admin/src/domains/inventory/components/entity-edit-forms/types.ts @@ -0,0 +1,38 @@ +import type { OutputData } from "@editorjs/editorjs"; +import type { UseFormSetError, UseFormSetValue } from "react-hook-form"; +import type { ApiFile } from "@/graphql/types"; + +export interface EntityIdentityFormValues { + title: string; + handle: string; +} + +export interface EntityContentFormValues { + description: OutputData | null; + excerpt: OutputData | null; +} + +export interface EntitySeoFormValues { + seoTitle: string; + seoDescription: string; + ogTitle: string; + ogDescription: string; + ogImage: ApiFile | null; +} + +export interface EntityIdentitySubmitHelpers { + setError: UseFormSetError; +} + +export interface EntitySeoSubmitHelpers { + setError: UseFormSetError; +} + +export interface EntityContentExtraRenderContext { + setValue: UseFormSetValue; +} + +export type EntityEditSubmitResult = + | boolean + | void + | Promise; diff --git a/admin/src/domains/inventory/components/inventory-sidebar-config-loader.tsx b/admin/src/domains/inventory/components/inventory-sidebar-config-loader.tsx new file mode 100644 index 000000000..89d5598d3 --- /dev/null +++ b/admin/src/domains/inventory/components/inventory-sidebar-config-loader.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import type { SidebarItem } from "@/registry"; +import { useDynamicSidebarStore } from "@/layouts/app/components/sidebar/dynamic-sidebar-store"; +import { useWarehouses } from "@/domains/inventory/warehouse/hooks"; + +const STOCK_SIDEBAR_KEY = "stock"; +const WAREHOUSES_SIDEBAR_LIMIT = 100; + +function getWarehouseLabel(warehouse: { + name?: string | null; + code?: string | null; + id: string; +}) { + return warehouse.name || warehouse.code || warehouse.id; +} + +export function InventorySidebarConfigLoader() { + const { warehouses } = useWarehouses({ first: WAREHOUSES_SIDEBAR_LIMIT }); + const setChildren = useDynamicSidebarStore((state) => state.setChildren); + const clearChildren = useDynamicSidebarStore((state) => state.clearChildren); + + const warehouseSidebarItems = useMemo( + () => + warehouses.map((warehouse, index) => ({ + key: `inventory-warehouse-${warehouse.id}`, + label: getWarehouseLabel(warehouse), + order: 10 + index, + path: `/:orgName/:storeName/inventory/${encodeURIComponent( + warehouse.id, + )}`, + })), + [warehouses], + ); + + useEffect(() => { + setChildren(STOCK_SIDEBAR_KEY, warehouseSidebarItems); + }, [setChildren, warehouseSidebarItems]); + + useEffect( + () => () => { + clearChildren(STOCK_SIDEBAR_KEY); + }, + [clearChildren], + ); + + return null; +} diff --git a/admin/src/domains/inventory/coupons/page/page.tsx b/admin/src/domains/inventory/coupons/page/page.tsx new file mode 100644 index 000000000..652caeb43 --- /dev/null +++ b/admin/src/domains/inventory/coupons/page/page.tsx @@ -0,0 +1,5 @@ +import { EmptySectionPage } from "@/shared/components/empty-section-page"; + +export default function CouponsPage() { + return ; +} diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/discount-details-card.styles.ts b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-details-card.styles.ts new file mode 100644 index 000000000..1c0c82b01 --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-details-card.styles.ts @@ -0,0 +1,418 @@ +import { createStyles } from "antd-style"; + +export const useDiscountDetailsCardStyles = createStyles(() => ({ + card: { + width: "100%", + }, +})); + +export const useDiscountSummaryStyles = createStyles(({ token }) => ({ + header: { + marginBottom: token.marginXS, + paddingBottom: token.paddingXS, + }, + statusTag: { + margin: 0, + display: "inline-flex", + alignItems: "center", + gap: token.marginXXS, + fontWeight: token.fontWeightStrong, + }, + metaText: { + fontSize: token.fontSizeSM, + }, + title: { + "&&": { + margin: 0, + fontSize: token.fontSizeHeading3, + }, + }, + chips: { + flexWrap: "wrap", + }, + identityChip: { + margin: 0, + display: "inline-flex", + alignItems: "center", + gap: token.marginXXS, + paddingInline: token.paddingXS, + background: token.colorFillQuaternary, + borderColor: token.colorBorder, + }, + chipLabel: { + fontSize: token.fontSizeSM, + textTransform: "uppercase", + letterSpacing: "0.3px", + }, + chipValue: { + fontSize: 11, + fontWeight: token.fontWeightStrong, + }, + divider: { + marginBlock: token.marginXS, + }, + summaryControls: { + marginBottom: token.marginXS, + }, + periodControl: { + "&&": { + fontSize: token.fontSizeSM, + }, + }, + kpiGrid: { + display: "grid", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gap: token.marginXS, + "@media (max-width: 768px)": { + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + }, + "@media (max-width: 480px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + kpiTile: { + minWidth: 0, + padding: `${token.paddingSM}px ${token.padding}px`, + borderRadius: token.borderRadiusLG, + border: `1px solid ${token.colorBorderSecondary}`, + background: token.colorFillQuaternary, + }, + kpiLabel: { + display: "block", + marginBottom: token.marginXXS, + color: token.colorTextTertiary, + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.3px", + }, + kpiValue: { + "&&": { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + fontSize: token.fontSizeXL, + fontWeight: token.fontWeightStrong, + lineHeight: 1.2, + }, + }, + trend: { + flexShrink: 0, + fontSize: 10, + fontWeight: token.fontWeightStrong, + }, + trendPositive: { + color: token.colorSuccess, + }, + trendNeutral: { + color: token.colorTextTertiary, + }, +})); + +export const useDiscountSectionStyles = createStyles(({ token }) => ({ + section: { + width: "100%", + }, + compactHeader: { + marginBottom: token.marginXS, + paddingBottom: token.paddingXS, + }, + headerAction: { + color: token.colorTextSecondary, + }, + muted: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + primaryIcon: { + color: token.colorPrimary, + }, + neutralIcon: { + color: token.colorTextQuaternary, + }, + channelIconActive: { + display: "inline-flex", + color: token.colorPrimary, + fontSize: token.fontSizeLG, + }, + channelIconInactive: { + display: "inline-flex", + color: token.colorTextQuaternary, + fontSize: token.fontSizeLG, + }, + eyebrow: { + color: token.colorTextTertiary, + fontSize: 10, + fontWeight: token.fontWeightStrong, + letterSpacing: "0.3px", + textTransform: "uppercase", + }, + primaryEyebrow: { + color: token.colorPrimary, + }, + detailList: { + marginTop: token.marginXS, + }, + valueTypeLabel: { + "&&": { + color: token.colorTextSecondary, + fontSize: token.fontSize, + fontWeight: 500, + }, + }, + valueDetailRow: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) max-content", + alignItems: "baseline", + gap: token.marginSM, + }, + valueDetailLabel: { + minWidth: 0, + }, + valueDetailValue: { + textAlign: "end", + whiteSpace: "nowrap", + }, + discountMainValue: { + "&&": { + marginBlock: `${token.marginXS}px ${token.marginXXS}px`, + lineHeight: 1.1, + }, + }, + kpiGrid: { + display: "grid", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gap: token.marginXS, + marginTop: token.marginXS, + "@media (max-width: 700px)": { + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + }, + "@media (max-width: 420px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + buyXTimeline: { + "&&": { + marginTop: token.marginXS, + paddingInlineStart: token.paddingXS, + }, + "&& .ant-timeline-item": { + paddingBottom: token.padding, + }, + "&& .ant-timeline-item:last-child": { + paddingBottom: 0, + }, + "&& .ant-timeline-item-content": { + minHeight: 48, + insetBlockStart: -5, + }, + }, + scopeHeader: { + marginBottom: token.marginXS, + }, + entityList: { + display: "flex", + flexDirection: "column", + gap: 0, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + overflow: "hidden", + }, + targetGrid: { + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: token.marginXS, + "@media (max-width: 640px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + entityRow: { + minWidth: 0, + padding: `${token.paddingXS}px ${token.paddingSM}px`, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + }, + listRow: { + minWidth: 0, + padding: `${token.paddingXS}px ${token.paddingSM}px`, + background: token.colorBgContainer, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { + borderBottom: 0, + }, + }, + entityTitle: { + minWidth: 0, + }, + mono: { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 11, + }, + warningBox: { + padding: token.paddingSM, + border: `1px solid ${token.colorWarningBorder}`, + borderRadius: token.borderRadiusLG, + background: token.colorWarningBg, + }, + optionList: { + display: "flex", + flexDirection: "column", + gap: token.marginXS, + }, + option: { + minWidth: 0, + padding: `${token.paddingXS}px ${token.paddingSM}px`, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + }, + optionEnabled: { + borderColor: token.colorPrimaryBorder, + background: token.colorPrimaryBg, + }, + caption: { + display: "block", + marginTop: token.marginXS, + color: token.colorTextTertiary, + fontSize: 11, + }, + scheduleGrid: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) minmax(300px, 0.9fr)", + gap: token.marginXS, + "@media (max-width: 560px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + availabilityTimeline: { + "&&": { + margin: 0, + paddingTop: 2, + }, + "&& .ant-timeline-item": { + paddingBottom: token.paddingSM, + }, + "&& .ant-timeline-item:last-child": { + paddingBottom: 0, + }, + "&& .ant-timeline-item-wrapper": { + columnGap: token.marginSM, + }, + "&& .ant-timeline-item-icon": { + width: 10, + minWidth: 10, + flexBasis: 10, + marginInlineStart: 0, + }, + "&& .ant-timeline-item-content": { + minHeight: 34, + insetBlockStart: -5, + marginInlineStart: 0, + }, + "&& .ant-timeline-item-rail": { + insetInlineStart: 5, + top: 8, + bottom: -8, + borderInlineStartWidth: 1, + }, + }, + policyCard: { + padding: `${token.paddingXS}px ${token.paddingSM}px`, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + }, + codeRow: { + padding: token.paddingXS, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + }, + codeRowContent: { + display: "grid", + gridTemplateColumns: "32px minmax(0, 1fr) max-content max-content", + alignItems: "center", + columnGap: 10, + "@media (max-width: 640px)": { + gridTemplateColumns: "32px minmax(0, 1fr)", + rowGap: token.marginXS, + }, + }, + codeDetails: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: 0, + }, + codeMeta: { + "&&": { + fontSize: token.fontSizeSM, + lineHeight: "18px", + }, + }, + codeList: { + display: "flex", + flexDirection: "column", + gap: token.marginXS, + }, + codeUsage: { + flexShrink: 0, + textAlign: "end", + whiteSpace: "nowrap", + "@media (max-width: 640px)": { + gridColumn: 2, + alignItems: "flex-start", + textAlign: "start", + }, + }, + codeStatuses: { + "& > .ant-tag": { + marginInlineEnd: 0, + paddingInline: 7, + fontSize: token.fontSizeSM, + lineHeight: "16px", + }, + "@media (max-width: 640px)": { + gridColumn: 2, + justifyContent: "flex-start", + }, + }, + codeValue: { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontWeight: token.fontWeightStrong, + lineHeight: "20px", + }, + codeUsagePrimary: { + "&&": { + fontSize: token.fontSizeSM, + lineHeight: "18px", + }, + }, + codeUsageSecondary: { + "&&": { + fontSize: token.fontSizeSM, + lineHeight: "18px", + }, + }, + externalLink: { + minWidth: 0, + }, + externalRowWarning: { + borderColor: token.colorWarningBorder, + background: token.colorWarningBg, + boxShadow: `inset 0 0 0 1px ${token.colorWarningBorder}`, + }, + inlineEmpty: { + minHeight: 52, + padding: token.paddingSM, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: token.marginXS, + color: token.colorTextTertiary, + textAlign: "center", + }, +})); diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/discount-details-card.tsx b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-details-card.tsx new file mode 100644 index 000000000..230123bab --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-details-card.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Flex } from "antd"; +import { useDefaultCurrency } from "@/domains/workspace"; +import { + AppliesToSection, + AvailabilityLimitsSection, + ChannelsSection, + CombinationsSection, + CustomerEligibilitySection, + DiscountCodesSection, + DiscountSummarySection, + DiscountTagsSection, + ExternalReferencesSection, + ValueUsageSection, +} from "./sections"; +import { useDiscountDetailsCardStyles } from "./discount-details-card.styles"; +import type { DiscountDetailsCardProps } from "./types"; + +export function DiscountDetailsCard({ + discount, + onEditSection, + onEditValueTargets, + editableSections, + onRefresh, + onArchived, + onViewActivity, +}: DiscountDetailsCardProps) { + const { styles } = useDiscountDetailsCardStyles(); + const currency = useDefaultCurrency() ?? discount.currency; + const editHandler = (section: Parameters>[0]) => + onEditSection && (!editableSections || editableSections.includes(section)) + ? () => onEditSection(section) + : undefined; + + return ( + + + + + + + + + + + + + ); +} diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/discount-section-icon.tsx b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-section-icon.tsx new file mode 100644 index 000000000..35cb4867b --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-section-icon.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Avatar } from "antd"; +import { createStyles } from "antd-style"; + +type DiscountSectionIconTone = + | "primary" + | "primaryBordered" + | "primaryOutline" + | "neutral" + | "warning"; + +interface DiscountSectionIconProps { + icon: ReactNode; + tone?: DiscountSectionIconTone; + shape?: "circle" | "square"; + size?: number; +} + +const useStyles = createStyles(({ token }) => ({ + icon: { + "&&": { + flexShrink: 0, + fontSize: token.fontSize, + border: "1px solid transparent", + }, + }, + primary: { + "&&": { + color: token.colorPrimary, + background: token.colorPrimaryBg, + borderColor: "transparent", + }, + }, + primaryOutline: { + "&&": { + color: token.colorPrimary, + background: token.colorBgContainer, + borderColor: token.colorPrimaryBorder, + }, + }, + primaryBordered: { + "&&": { + color: token.colorPrimary, + background: token.colorPrimaryBg, + borderColor: token.colorPrimaryBorder, + }, + }, + neutral: { + "&&": { + color: token.colorTextTertiary, + background: token.colorFillSecondary, + borderColor: token.colorBorderSecondary, + }, + }, + warning: { + "&&": { + color: token.colorWarning, + background: token.colorWarningBg, + borderColor: token.colorWarningBorder, + }, + }, +})); + +export function DiscountSectionIcon({ + icon, + tone = "primary", + shape = "circle", + size = 28, +}: DiscountSectionIconProps) { + const { styles, cx } = useStyles(); + + return ( + + ); +} diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/discount-status-tag.tsx b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-status-tag.tsx new file mode 100644 index 000000000..d9ff6372f --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/discount-status-tag.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Tag } from "antd"; +import { + LuArchive, + LuCircleCheck, + LuCirclePause, + LuClock3, + LuFilePenLine, + LuTimerOff, +} from "react-icons/lu"; +import { + DiscountEffectiveStatus, + DiscountMethod, +} from "@/graphql/types"; +import { formatDiscountEnum } from "./formatters"; + +const STATUS_PRESENTATION: Record< + DiscountEffectiveStatus, + { color: string; icon: ReactNode } +> = { + [DiscountEffectiveStatus.Active]: { + color: "success", + icon: , + }, + [DiscountEffectiveStatus.Archived]: { + color: "default", + icon: , + }, + [DiscountEffectiveStatus.Draft]: { + color: "default", + icon: , + }, + [DiscountEffectiveStatus.Expired]: { + color: "error", + icon: , + }, + [DiscountEffectiveStatus.Paused]: { + color: "warning", + icon: , + }, + [DiscountEffectiveStatus.Scheduled]: { + color: "processing", + icon: , + }, +}; + +interface DiscountStatusTagProps { + status: DiscountEffectiveStatus; + method?: DiscountMethod; + compact?: boolean; + className?: string; +} + +export function DiscountStatusTag({ + status, + method, + compact = false, + className, +}: DiscountStatusTagProps) { + const presentation = STATUS_PRESENTATION[status]; + const methodLabel = + method === DiscountMethod.Code + ? "Code" + : method === DiscountMethod.Automatic + ? "Automatic" + : null; + const label = [methodLabel, formatDiscountEnum(status)] + .filter(Boolean) + .join(" · "); + + return ( + + {label} + + ); +} diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/formatters.ts b/admin/src/domains/inventory/discounts/components/discount-details-card/formatters.ts new file mode 100644 index 000000000..fe6d1d320 --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/formatters.ts @@ -0,0 +1,95 @@ +import { + DiscountKind, + DiscountMethod, + type CurrencyCode, +} from "@/graphql/types"; +import { formatPrice } from "@/domains/inventory/products/utils/price-formatting"; + +export function formatDiscountEnum(value: string): string { + return value + .toLowerCase() + .replaceAll("_", " ") + .replace(/^./, (letter) => letter.toUpperCase()); +} + +const DISCOUNT_METHOD_LABELS: Record = { + [DiscountMethod.Automatic]: "Automatic", + [DiscountMethod.Code]: "Discount code", +}; + +const DISCOUNT_KIND_LABELS: Record = { + [DiscountKind.AmountOffOrder]: "Amount off order", + [DiscountKind.AmountOffProducts]: "Amount off products", + [DiscountKind.BuyXGetY]: "Buy X Get Y", + [DiscountKind.FreeShipping]: "Free shipping", +}; + +export function formatDiscountMethod(value: DiscountMethod): string { + return DISCOUNT_METHOD_LABELS[value]; +} + +export function formatDiscountKind(value: DiscountKind): string { + return DISCOUNT_KIND_LABELS[value]; +} + +const detailDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +export function formatDiscountDate(value: string): string { + return detailDateFormatter.format(new Date(value)); +} + +const compactDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +export function formatDiscountCompactDate(value: string): string { + return compactDateFormatter.format(new Date(value)); +} + +const detailDateTimeFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +export function formatDiscountDateTime(value: string): string { + return detailDateTimeFormatter.format(new Date(value)); +} + +export function formatDiscountCount(value: number | null | undefined): string { + if (value == null) return "—"; + return new Intl.NumberFormat("en-US").format(value); +} + +export function formatDiscountMoney( + amountMinor: number | null | undefined, + currency: CurrencyCode | null, +): string { + if (amountMinor == null || !currency) return "—"; + const formatted = formatPrice(amountMinor, currency, "en-US"); + + return currency === "UAH" + ? formatted.replace("₴", "₴\u00A0") + : formatted; +} + +export function formatDiscountPercentage( + percentageBps: number | null | undefined, +): string { + if (percentageBps == null) return "—"; + const percentage = percentageBps / 100; + return `${new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + }).format(percentage)}%`; +} + +export function getShortDiscountId(id: string): string { + return id.slice(0, 8).toUpperCase(); +} diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/index.ts b/admin/src/domains/inventory/discounts/components/discount-details-card/index.ts new file mode 100644 index 000000000..144e3b3e2 --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/index.ts @@ -0,0 +1,5 @@ +export { DiscountDetailsCard } from "./discount-details-card"; +export type { + DiscountDetailsCardProps, + DiscountDetailsSection, +} from "./types"; diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/sections/applies-to-section.tsx b/admin/src/domains/inventory/discounts/components/discount-details-card/sections/applies-to-section.tsx new file mode 100644 index 000000000..cd4657862 --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/sections/applies-to-section.tsx @@ -0,0 +1,389 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Button, + Dropdown, + Flex, + Tag, + Timeline, + Typography, +} from "antd"; +import { + LuBox, + LuEllipsis, + LuGift, + LuPencil, + LuShoppingCart, + LuTruck, + LuTriangleAlert, +} from "react-icons/lu"; +import type { + ApiDiscount, + ApiDiscountTarget, + ApiDiscountTargetSelection, +} from "@/graphql/types"; +import { + DiscountKind, + DiscountReferenceStatus, + DiscountTargetRole, + DiscountTargetType, +} from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import { DiscountSectionIcon } from "../discount-section-icon"; +import { useDiscountSectionStyles } from "../discount-details-card.styles"; +import { formatDiscountEnum } from "../formatters"; + +interface AppliesToSectionProps { + discount: ApiDiscount; + onEdit?: () => void; +} + +function getTargetTitle(target: ApiDiscountTarget): string { + const entity = target.target; + if (!entity) return "Unresolved catalog reference"; + if (entity.__typename === "Product") return entity.title; + if (entity.__typename === "Variant") return entity.handle; + if (entity.__typename === "Category") return entity.name; + return target.targetId; +} + +function getTargetDescription(target: ApiDiscountTarget): string { + const entity = target.target; + if (!entity) return "The referenced catalog entity is no longer available."; + if (entity.__typename === "Product") return "Product"; + if (entity.__typename === "Variant") { + const sku = entity.inventoryItem?.sku; + return ["Variant", sku ? `SKU ${sku}` : null].filter(Boolean).join(" · "); + } + if (entity.__typename === "Category") return "Category"; + return formatDiscountEnum(target.targetType); +} + +function SelectionContent({ + selection, +}: { + selection: ApiDiscountTargetSelection; +}) { + const { styles } = useDiscountSectionStyles(); + const staleCount = selection.targets.filter( + (target) => target.referenceStatus === DiscountReferenceStatus.Stale, + ).length; + const visibleTargets = selection.targets.slice(0, 3); + + if (selection.targetType === DiscountTargetType.AllProducts) { + return ( +
    + + } + shape="square" + size={32} + /> + + All products + + The complete product catalog is eligible. + + + +
    + ); + } + + return ( + + {staleCount > 0 && ( + + + + {staleCount} catalog reference{staleCount === 1 ? "" : "s"} need + attention. + + + )} + + + Selected {formatDiscountEnum(selection.targetType).toLowerCase()} + + + {selection.targets.length}{" "} + {formatDiscountEnum(selection.targetType).toLowerCase()} + + +
    + {visibleTargets.map((target, index) => ( +
    + + + + ) : ( + + ) + } + shape="square" + size={32} + tone={ + target.referenceStatus === DiscountReferenceStatus.Stale + ? "warning" + : index === 0 + ? "primary" + : "neutral" + } + /> + + + {getTargetTitle(target)} + + + {getTargetDescription(target)} + + + + {target.referenceStatus === DiscountReferenceStatus.Stale && ( + Stale + )} + +
    + ))} +
    + {selection.targets.length > visibleTargets.length && ( + + +{selection.targets.length - visibleTargets.length} more selected + + )} +
    + ); +} + +function BuyXFlow({ + discount, + qualifier, + benefit, +}: { + discount: ApiDiscount; + qualifier: ApiDiscountTargetSelection; + benefit: ApiDiscountTargetSelection; +}) { + const { styles } = useDiscountSectionStyles(); + const rule = + discount.rule?.__typename === "DiscountBuyXGetYRule" + ? discount.rule + : null; + const qualifierTitles = qualifier.targets + .slice(0, 2) + .map(getTargetTitle); + const qualifierSummary = [ + qualifierTitles[0], + qualifier.targets.length > 1 + ? `+${qualifier.targets.length - 1} more` + : null, + ] + .filter(Boolean) + .join(" · "); + const benefitTarget = benefit.targets[0]; + + return ( + } + tone="neutral" + size={24} + /> + ), + children: ( + + + When · Customer buys + + + {rule?.requiredQuantity ?? qualifier.targets.length} eligible + items from selected{" "} + {formatDiscountEnum(qualifier.targetType).toLowerCase()} + + + {qualifierSummary} + + + ), + }, + { + dot: ( + } + tone="primaryBordered" + size={24} + /> + ), + children: ( + + + Then · Customer gets + + + {benefitTarget + ? getTargetTitle(benefitTarget) + : "Selected benefit items"} + + + {rule?.benefitQuantity ?? benefit.targets.length} eligible item + free + {rule?.usesPerOrderLimit != null + ? ` · Up to ${rule.usesPerOrderLimit} times per order` + : ""} + + + ), + }, + ]} + /> + ); +} + +function ScopeHeader({ + label, +}: { + label: string; +}) { + const { styles } = useDiscountSectionStyles(); + + return ( + + {label} + No catalog targets + + ); +} + +function ScopeContent({ + icon, + title, + description, +}: { + icon: ReactNode; + title: string; + description: string; +}) { + const { styles } = useDiscountSectionStyles(); + + return ( +
    + + + + {title} + {description} + + +
    + ); +} + +export function AppliesToSection({ + discount, + onEdit, +}: AppliesToSectionProps) { + const { styles } = useDiscountSectionStyles(); + const qualifier = discount.targetSelections.find( + (selection) => selection.role === DiscountTargetRole.Qualifier, + ); + const benefit = discount.targetSelections.find( + (selection) => selection.role === DiscountTargetRole.Benefit, + ); + const isConnectedFlow = + discount.kind === DiscountKind.BuyXGetY && qualifier && benefit; + const appliesToEntireOrder = + discount.kind === DiscountKind.AmountOffOrder; + const appliesToShipping = discount.kind === DiscountKind.FreeShipping; + + return ( + + , + "data-testid": "discount-targets-edit-menu-item", + onClick: onEdit, + }, + ], + }} + > + + ) : null} + {onEdit ? ( + + ) : null} + + ) : undefined + } + /> + +
    +
    +
    + + + + {valuePresentation.label} + + + + {formatRuleValue(discount, currency)} + + +
    + {valueDetails.map((detail) => ( +
    + + {detail.label} + + + {detail.value} + +
    + ))} +
    +
    +
    + +
    + setPeriod(value as ChartPeriod)} + label="Usage history" + valueFormatter={(value) => formatDiscountCount(value)} + showPointSymbols={false} + /> +
    +
    + +
    + + + + +
    +
    + ); +} diff --git a/admin/src/domains/inventory/discounts/components/discount-details-card/types.ts b/admin/src/domains/inventory/discounts/components/discount-details-card/types.ts new file mode 100644 index 000000000..2afde6051 --- /dev/null +++ b/admin/src/domains/inventory/discounts/components/discount-details-card/types.ts @@ -0,0 +1,32 @@ +import type { ApiDiscount, CurrencyCode } from "@/graphql/types"; + +export type DiscountDetailsSection = + | "summary" + | "value-usage" + | "targets" + | "eligibility" + | "channels" + | "combinations" + | "availability" + | "tags" + | "codes" + | "external-references"; + +export interface DiscountDetailsCardProps { + discount: ApiDiscount; + onEditSection?: (section: DiscountDetailsSection) => void; + onEditValueTargets?: () => void; + editableSections?: readonly DiscountDetailsSection[]; + onViewActivity?: () => void; + onRefresh?: () => Promise; + onArchived?: () => void; +} + +export interface DiscountSummarySectionProps { + discount: ApiDiscount; + currency: CurrencyCode | null; + onEdit?: () => void; + onEditValueTargets?: () => void; + onRefresh?: () => Promise; + onArchived?: () => void; +} diff --git a/admin/src/domains/inventory/discounts/graphql/fragments.ts b/admin/src/domains/inventory/discounts/graphql/fragments.ts new file mode 100644 index 000000000..92b1b2297 --- /dev/null +++ b/admin/src/domains/inventory/discounts/graphql/fragments.ts @@ -0,0 +1,235 @@ +import { gql } from "@apollo/client"; + +export const DISCOUNT_LIST_FRAGMENT = gql` + fragment DiscountListFields on Discount { + id + title + primaryCode + codesCount + method + kind + discountClass + effectiveStatus + currency + priority + usageLimit + reservedUsageCount + usageCount + startsAt + endsAt + tags + channelCodes + updatedAt + } +`; + +export const DISCOUNT_DETAILS_FRAGMENT = gql` + fragment DiscountDetailsFields on Discount { + id + revision + title + primaryCode + codesCount + method + kind + discountClass + currency + state + effectiveStatus + priority + usageLimit + reservedUsageCount + usageCount + appliesOncePerCustomer + appliesOnOneTimePurchase + appliesOnSubscription + startsAt + endsAt + tags + channelCodes + featuredChannelCodes + combinesWithProductDiscounts + combinesWithOrderDiscounts + combinesWithShippingDiscounts + createdById + createdAt + updatedAt + archivedAt + + rule { + __typename + ... on DiscountAmountOffRule { + valueType + percentageBps + amountMinor + allocationMethod + maximumDiscountMinor + } + ... on DiscountBuyXGetYRule { + requirementType + requiredQuantity + requiredSubtotalMinor + benefitQuantity + benefitValueType + benefitPercentageBps + benefitAmountMinor + usesPerOrderLimit + } + ... on DiscountFreeShippingRule { + maximumShippingPriceMinor + } + } + + minimumRequirement { + requirementType + subtotalMinor + quantity + } + + targetSelections { + role + targetType + targets { + targetId + targetType + referenceStatus + referenceStatusChangedAt + referenceCheckedAt + createdAt + target { + __typename + ... on Product { + id + title + handle + } + ... on Variant { + id + handle + product { + id + title + } + inventoryItem { + sku + } + } + ... on Category { + id + name + handle + } + } + } + } + + buyerContext { + type + createdAt + updatedAt + customers { + customerId + referenceStatus + referenceStatusChangedAt + referenceCheckedAt + createdAt + customer { + id + displayName + email + } + } + segments { + segmentId + referenceStatus + referenceStatusChangedAt + referenceCheckedAt + createdAt + } + } + + channels { + code + featured + createdAt + updatedAt + } + + combinations { + discountClass + createdAt + } + + usage { + usageLimit + reservedCount + committedCount + reversedCount + netCommittedCount + consumedCount + remainingCount + version + updatedAt + } + + codes(first: 20, orderBy: [{ field: updatedAt, direction: desc }]) { + totalCount + edges { + node { + id + code + normalizedCode + status + usageLimit + reservedCount + committedCount + reversedCount + usageCount + remainingCount + createdAt + updatedAt + disabledAt + } + } + } + + redemptions( + first: 100 + orderBy: [{ field: committedAt, direction: desc }] + ) { + totalCount + edges { + node { + id + orderId + status + amountMinor + committedAt + reversedAt + } + } + } + + externalReferences( + first: 20 + orderBy: [{ field: updatedAt, direction: desc }] + ) { + totalCount + edges { + node { + id + externalSystem + externalType + externalId + externalUrl + direction + syncStatus + lastSyncedAt + lastError + createdAt + updatedAt + } + } + } + } +`; diff --git a/admin/src/domains/inventory/discounts/graphql/index.ts b/admin/src/domains/inventory/discounts/graphql/index.ts new file mode 100644 index 000000000..de26c5003 --- /dev/null +++ b/admin/src/domains/inventory/discounts/graphql/index.ts @@ -0,0 +1,20 @@ +export { + DISCOUNT_DETAILS_FRAGMENT, + DISCOUNT_LIST_FRAGMENT, +} from "./fragments"; +export { DISCOUNT_DETAILS_QUERY, DISCOUNTS_QUERY } from "./queries"; +export { + DISCOUNT_CREATE_MUTATION, + DISCOUNT_UPDATE_MUTATION, +} from "./mutations"; +export type { + DiscountCreateMutationData, + DiscountCreateMutationVariables, + DiscountDetailsQueryData, + DiscountDetailsQueryVariables, + DiscountUpdateMutationData, + DiscountUpdateMutationVariables, + DiscountUpdateResult, + DiscountsQueryData, + DiscountsQueryVariables, +} from "./operation-types"; diff --git a/admin/src/domains/inventory/discounts/graphql/mutations.ts b/admin/src/domains/inventory/discounts/graphql/mutations.ts new file mode 100644 index 000000000..f3f825a2a --- /dev/null +++ b/admin/src/domains/inventory/discounts/graphql/mutations.ts @@ -0,0 +1,55 @@ +import { gql } from "@apollo/client"; +import { USER_ERROR_FRAGMENT } from "../../graphql/shared-fragments"; +import { + DISCOUNT_DETAILS_FRAGMENT, + DISCOUNT_LIST_FRAGMENT, +} from "./fragments"; + +export const DISCOUNT_CREATE_MUTATION = gql` + mutation DiscountCreate($input: DiscountCreateInput!) { + pricingMutation { + discountCreate(input: $input) { + discount { + ...DiscountListFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${DISCOUNT_LIST_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const DISCOUNT_UPDATE_MUTATION = gql` + mutation DiscountUpdate( + $discountId: ID! + $expectedRevision: Int! + $operations: DiscountUpdateInput! + ) { + pricingMutation { + discountUpdate( + discountId: $discountId + expectedRevision: $expectedRevision + operations: $operations + ) { + discount { + ...DiscountDetailsFields + } + operationResults { + type + applied + errors { + ...UserErrorFields + } + } + userErrors { + ...UserErrorFields + } + } + } + } + ${DISCOUNT_DETAILS_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; diff --git a/admin/src/domains/inventory/discounts/graphql/operation-types.ts b/admin/src/domains/inventory/discounts/graphql/operation-types.ts new file mode 100644 index 000000000..ea1d8aa1c --- /dev/null +++ b/admin/src/domains/inventory/discounts/graphql/operation-types.ts @@ -0,0 +1,65 @@ +import type { + ApiDiscountConnection, + ApiDiscount, + ApiDiscountCreateInput, + ApiDiscountCreatePayload, + ApiGenericUserError, + ApiDiscountOperationResult, + ApiDiscountOrderByInput, + ApiDiscountUpdateInput, + ApiDiscountUpdatePayload, + ApiDiscountWhereInput, + ApiPricingMutation, + ApiPricingQuery, +} from "@/graphql/types"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; + +export interface DiscountsQueryData { + pricingQuery: Pick & { + discounts: ApiDiscountConnection; + }; +} + +export interface DiscountsQueryVariables extends RelayCursorPaginationVariables { + where?: ApiDiscountWhereInput | null; + orderBy?: ApiDiscountOrderByInput[] | null; +} + +export interface DiscountDetailsQueryData { + pricingQuery: { + discount: ApiDiscount | null; + }; +} + +export interface DiscountDetailsQueryVariables { + id: string; +} + +export interface DiscountCreateMutationData { + pricingMutation: Pick & { + discountCreate: ApiDiscountCreatePayload; + }; +} + +export interface DiscountCreateMutationVariables { + input: ApiDiscountCreateInput; +} + +export interface DiscountUpdateMutationData { + pricingMutation: Pick & { + discountUpdate: ApiDiscountUpdatePayload; + }; +} + +export interface DiscountUpdateMutationVariables { + discountId: string; + expectedRevision: number; + operations: ApiDiscountUpdateInput; +} + +export interface DiscountUpdateResult { + discount: ApiDiscount | null; + operationResults: ApiDiscountOperationResult[]; + userErrors: ApiGenericUserError[]; + errors: ApiGenericUserError[]; +} diff --git a/admin/src/domains/inventory/discounts/graphql/queries.ts b/admin/src/domains/inventory/discounts/graphql/queries.ts new file mode 100644 index 000000000..65c8b9bd6 --- /dev/null +++ b/admin/src/domains/inventory/discounts/graphql/queries.ts @@ -0,0 +1,53 @@ +import { gql } from "@apollo/client"; +import { + DISCOUNT_DETAILS_FRAGMENT, + DISCOUNT_LIST_FRAGMENT, +} from "./fragments"; + +export const DISCOUNTS_QUERY = gql` + query Discounts( + $first: Int + $after: String + $last: Int + $before: String + $where: DiscountWhereInput + $orderBy: [DiscountOrderByInput!] + ) { + pricingQuery { + discounts( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...DiscountListFields + } + } + pageInfo { + startCursor + endCursor + hasPreviousPage + hasNextPage + } + totalCount + } + } + } + ${DISCOUNT_LIST_FRAGMENT} +`; + +export const DISCOUNT_DETAILS_QUERY = gql` + query DiscountDetails($id: ID!) { + pricingQuery { + discount(id: $id) { + ...DiscountDetailsFields + } + } + } + ${DISCOUNT_DETAILS_FRAGMENT} +`; diff --git a/admin/src/domains/inventory/discounts/hooks/index.ts b/admin/src/domains/inventory/discounts/hooks/index.ts new file mode 100644 index 000000000..191b94bc0 --- /dev/null +++ b/admin/src/domains/inventory/discounts/hooks/index.ts @@ -0,0 +1,4 @@ +export { useCreateDiscount } from "./use-create-discount"; +export { useDiscount } from "./use-discount"; +export { useDiscounts, type UseDiscountsReturn } from "./use-discounts"; +export { useUpdateDiscount } from "./use-update-discount"; diff --git a/admin/src/domains/inventory/discounts/hooks/use-create-discount.ts b/admin/src/domains/inventory/discounts/hooks/use-create-discount.ts new file mode 100644 index 000000000..038594d1b --- /dev/null +++ b/admin/src/domains/inventory/discounts/hooks/use-create-discount.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiDiscount, + ApiDiscountCreateInput, + ApiGenericUserError, +} from "@/graphql/types"; +import { DISCOUNT_CREATE_MUTATION } from "../graphql"; +import type { + DiscountCreateMutationData, + DiscountCreateMutationVariables, +} from "../graphql/operation-types"; + +interface CreateDiscountResult { + discount: ApiDiscount | null; + userErrors: ApiGenericUserError[]; +} + +interface UseCreateDiscountReturn { + createDiscount: (input: ApiDiscountCreateInput) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useCreateDiscount(): UseCreateDiscountReturn { + const [createDiscountMutation, { loading, error, reset }] = useMutation< + DiscountCreateMutationData, + DiscountCreateMutationVariables + >(DISCOUNT_CREATE_MUTATION); + + const createDiscount = useCallback( + async (input: ApiDiscountCreateInput): Promise => { + try { + const result = await createDiscountMutation({ variables: { input } }); + const payload = result.data?.pricingMutation.discountCreate; + + return { + discount: payload?.discount ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (caughtError) { + const message = + caughtError instanceof Error + ? caughtError.message + : "An unexpected error occurred"; + + return { + discount: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [createDiscountMutation], + ); + + return { + createDiscount, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/discounts/hooks/use-discount.ts b/admin/src/domains/inventory/discounts/hooks/use-discount.ts new file mode 100644 index 000000000..8606a370f --- /dev/null +++ b/admin/src/domains/inventory/discounts/hooks/use-discount.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import type { ApiDiscount } from "@/graphql/types"; +import { DISCOUNT_DETAILS_QUERY } from "../graphql"; +import type { + DiscountDetailsQueryData, + DiscountDetailsQueryVariables, +} from "../graphql/operation-types"; + +interface UseDiscountReturn { + discount: ApiDiscount | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useDiscount(id?: string | null): UseDiscountReturn { + const { data, previousData, loading, error, refetch } = useQuery< + DiscountDetailsQueryData, + DiscountDetailsQueryVariables + >(DISCOUNT_DETAILS_QUERY, { + variables: { id: id ?? "" }, + skip: !id, + fetchPolicy: "cache-and-network", + }); + + const effectiveData = data ?? previousData; + + return { + discount: effectiveData?.pricingQuery.discount ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/inventory/discounts/hooks/use-discounts.ts b/admin/src/domains/inventory/discounts/hooks/use-discounts.ts new file mode 100644 index 000000000..6f7d3795c --- /dev/null +++ b/admin/src/domains/inventory/discounts/hooks/use-discounts.ts @@ -0,0 +1,49 @@ +"use client"; + +import type { + ApiDiscount, + ApiDiscountConnection, + ApiPageInfo, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import { DISCOUNTS_QUERY } from "../graphql"; +import type { + DiscountsQueryData, + DiscountsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseDiscountsReturn { + discounts: ApiDiscount[]; + connection: ApiDiscountConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useDiscounts( + variables: DiscountsQueryVariables, +): UseDiscountsReturn { + const result = useRelayConnectionQuery< + DiscountsQueryData, + DiscountsQueryVariables, + ApiDiscount, + ApiDiscountConnection + >({ + query: DISCOUNTS_QUERY, + variables, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.pricingQuery.discounts, + }); + + return { + discounts: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/admin/src/domains/inventory/discounts/hooks/use-update-discount.ts b/admin/src/domains/inventory/discounts/hooks/use-update-discount.ts new file mode 100644 index 000000000..c0ca3e962 --- /dev/null +++ b/admin/src/domains/inventory/discounts/hooks/use-update-discount.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiDiscountUpdateInput } from "@/graphql/types"; +import { + DISCOUNT_UPDATE_MUTATION, + type DiscountUpdateMutationData, + type DiscountUpdateMutationVariables, + type DiscountUpdateResult, +} from "../graphql"; + +interface UpdateDiscountInput { + discountId: string; + expectedRevision: number; + operations: ApiDiscountUpdateInput; +} + +export function useUpdateDiscount() { + const [updateDiscountMutation, { loading, error, reset }] = useMutation< + DiscountUpdateMutationData, + DiscountUpdateMutationVariables + >(DISCOUNT_UPDATE_MUTATION); + + const updateDiscount = useCallback( + async (input: UpdateDiscountInput): Promise => { + try { + const result = await updateDiscountMutation({ + variables: input, + fetchPolicy: "no-cache", + }); + const payload = result.data?.pricingMutation.discountUpdate; + const operationResults = payload?.operationResults ?? []; + const userErrors = payload?.userErrors ?? []; + const operationErrors = operationResults.flatMap( + (operation) => operation.errors, + ); + + return { + discount: payload?.discount ?? null, + operationResults, + userErrors, + errors: [...userErrors, ...operationErrors], + }; + } catch (caughtError) { + const message = + caughtError instanceof Error + ? caughtError.message + : "An unexpected error occurred"; + const userErrors = [{ message, code: "UNEXPECTED_ERROR" }]; + + return { + discount: null, + operationResults: [], + userErrors, + errors: userErrors, + }; + } + }, + [updateDiscountMutation], + ); + + return { + updateDiscount, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/discounts/mappers/discount-availability-update.mapper.ts b/admin/src/domains/inventory/discounts/mappers/discount-availability-update.mapper.ts new file mode 100644 index 000000000..0ab8f63a7 --- /dev/null +++ b/admin/src/domains/inventory/discounts/mappers/discount-availability-update.mapper.ts @@ -0,0 +1,132 @@ +import type { + ApiDiscount, + ApiDiscountUpdateInput, +} from "@/graphql/types"; +import { DiscountClass, DiscountMethod } from "@/graphql/types"; + +export interface DiscountAvailabilityFormValues { + appliesOnOneTimePurchase: boolean; + appliesOnSubscription: boolean; + usageLimit: number | null; + appliesOncePerCustomer: boolean; + combinesWith: DiscountClass[]; + startsAt: string; + endsAt: string; +} + +function isoToLocalDateTime(value: string | null | undefined): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function localDateTimeToIso(value: string): string { + return new Date(value).toISOString(); +} + +function normalizeCount(value: string | number | null | undefined): number { + if (value == null) return 0; + const result = Number(value); + return Number.isSafeInteger(result) ? result : 0; +} + +export function createDiscountAvailabilityFormValues( + discount: ApiDiscount, +): DiscountAvailabilityFormValues { + const combinesWith: DiscountClass[] = []; + if (discount.combinesWithProductDiscounts) { + combinesWith.push(DiscountClass.Product); + } + if (discount.combinesWithOrderDiscounts) { + combinesWith.push(DiscountClass.Order); + } + if (discount.combinesWithShippingDiscounts) { + combinesWith.push(DiscountClass.Shipping); + } + + return { + appliesOnOneTimePurchase: discount.appliesOnOneTimePurchase, + appliesOnSubscription: discount.appliesOnSubscription, + usageLimit: + discount.usageLimit == null ? null : Number(discount.usageLimit), + appliesOncePerCustomer: + discount.method === DiscountMethod.Code && + discount.appliesOncePerCustomer, + combinesWith, + startsAt: isoToLocalDateTime(discount.startsAt), + endsAt: isoToLocalDateTime(discount.endsAt), + }; +} + +export function validateDiscountAvailabilityForm( + discount: ApiDiscount, + values: DiscountAvailabilityFormValues, +): string[] { + const errors: string[] = []; + + if ( + !values.appliesOnOneTimePurchase && + !values.appliesOnSubscription + ) { + errors.push("Select at least one purchase mode."); + } + if ( + values.usageLimit != null && + (!Number.isSafeInteger(values.usageLimit) || values.usageLimit < 1) + ) { + errors.push("Usage limit must be a positive whole number."); + } + const minimumUsageLimit = + normalizeCount(discount.reservedUsageCount) + + normalizeCount(discount.usageCount); + if ( + values.usageLimit != null && + values.usageLimit < minimumUsageLimit + ) { + errors.push( + `Usage limit cannot be below ${minimumUsageLimit} reserved and consumed uses.`, + ); + } + if (!values.startsAt || Number.isNaN(new Date(values.startsAt).getTime())) { + errors.push("Start date is required."); + } + if ( + values.endsAt && + (Number.isNaN(new Date(values.endsAt).getTime()) || + new Date(values.endsAt) <= new Date(values.startsAt)) + ) { + errors.push("End date must be after the start date."); + } + + return errors; +} + +export function buildDiscountAvailabilityUpdateInput( + discount: ApiDiscount, + values: DiscountAvailabilityFormValues, +): ApiDiscountUpdateInput { + return { + definition: { + purchaseModes: { + appliesOnOneTimePurchase: values.appliesOnOneTimePurchase, + appliesOnSubscription: values.appliesOnSubscription, + }, + usage: { + usageLimit: values.usageLimit, + appliesOncePerCustomer: + discount.method === DiscountMethod.Code + ? values.appliesOncePerCustomer + : false, + }, + schedule: { + startsAt: localDateTimeToIso(values.startsAt), + endsAt: values.endsAt + ? localDateTimeToIso(values.endsAt) + : null, + }, + }, + combinesWith: values.combinesWith, + }; +} diff --git a/admin/src/domains/inventory/discounts/mappers/discount-create.mapper.ts b/admin/src/domains/inventory/discounts/mappers/discount-create.mapper.ts new file mode 100644 index 000000000..a8781618b --- /dev/null +++ b/admin/src/domains/inventory/discounts/mappers/discount-create.mapper.ts @@ -0,0 +1,22 @@ +import type { ApiDiscountCreateInput, CurrencyCode } from "@/graphql/types"; +import { DiscountMethod } from "@/graphql/types"; +import type { CreateDiscountFormValues } from "../modals/create-discount-modal/schema"; + +interface BuildDiscountCreateInputOptions { + currency: CurrencyCode; + values: CreateDiscountFormValues; +} + +export function buildDiscountCreateInput({ + currency, + values, +}: BuildDiscountCreateInputOptions): ApiDiscountCreateInput { + return { + currency, + kind: values.kind, + method: values.method, + ...(values.method === DiscountMethod.Automatic + ? { title: values.title.trim() } + : {}), + }; +} diff --git a/admin/src/domains/inventory/discounts/mappers/discount-eligibility-channels-update.mapper.ts b/admin/src/domains/inventory/discounts/mappers/discount-eligibility-channels-update.mapper.ts new file mode 100644 index 000000000..1117383f2 --- /dev/null +++ b/admin/src/domains/inventory/discounts/mappers/discount-eligibility-channels-update.mapper.ts @@ -0,0 +1,111 @@ +import type { + ApiDiscount, + ApiDiscountUpdateInput, +} from "@/graphql/types"; +import { DiscountBuyerContextType } from "@/graphql/types"; + +export interface DiscountCustomerEditorRow { + id: string; + title: string; + email: string; +} + +export interface DiscountSegmentEditorRow { + id: string; + title: string; + customersCount: number | null; +} + +export interface DiscountChannelEditorRow { + code: string; + title: string; + enabled: boolean; + featured: boolean; +} + +export interface DiscountEligibilityChannelsFormValues { + buyerContextType: DiscountBuyerContextType; + customers: DiscountCustomerEditorRow[]; + segments: DiscountSegmentEditorRow[]; + channels: DiscountChannelEditorRow[]; +} + +const CHANNELS = [ + { code: "ONLINE_STORE", title: "Online store" }, + { code: "MOBILE_APP", title: "Mobile app" }, + { code: "POINT_OF_SALE", title: "Point of sale" }, +] as const; + +export function createDiscountEligibilityChannelsFormValues( + discount: ApiDiscount, +): DiscountEligibilityChannelsFormValues { + const context = discount.buyerContext; + + return { + buyerContextType: context?.type ?? DiscountBuyerContextType.All, + customers: + context?.customers.map((item) => ({ + id: item.customerId, + title: item.customer?.displayName ?? item.customerId, + email: item.customer?.email ?? "", + })) ?? [], + segments: + context?.segments.map((item) => ({ + id: item.segmentId, + title: item.segmentId, + customersCount: null, + })) ?? [], + channels: CHANNELS.map((channel) => { + const current = discount.channels.find( + (item) => item.code === channel.code, + ); + return { + ...channel, + enabled: Boolean(current), + featured: current?.featured ?? false, + }; + }), + }; +} + +export function validateDiscountEligibilityChannelsForm( + values: DiscountEligibilityChannelsFormValues, +): string[] { + if ( + values.buyerContextType === DiscountBuyerContextType.Customers && + values.customers.length === 0 + ) { + return ["Select at least one customer."]; + } + if ( + values.buyerContextType === DiscountBuyerContextType.Segments && + values.segments.length === 0 + ) { + return ["Select at least one customer segment."]; + } + return []; +} + +export function buildDiscountEligibilityChannelsUpdateInput( + values: DiscountEligibilityChannelsFormValues, +): ApiDiscountUpdateInput { + return { + eligibility: { + type: values.buyerContextType, + customerIds: + values.buyerContextType === DiscountBuyerContextType.Customers + ? values.customers.map((customer) => customer.id) + : [], + segmentIds: + values.buyerContextType === DiscountBuyerContextType.Segments + ? values.segments.map((segment) => segment.id) + : [], + }, + channels: values.channels + .filter((channel) => channel.enabled) + .map((channel) => ({ + code: channel.code, + featured: channel.featured, + })), + }; +} diff --git a/admin/src/domains/inventory/discounts/mappers/discount-errors.mapper.ts b/admin/src/domains/inventory/discounts/mappers/discount-errors.mapper.ts new file mode 100644 index 000000000..b5258d730 --- /dev/null +++ b/admin/src/domains/inventory/discounts/mappers/discount-errors.mapper.ts @@ -0,0 +1,19 @@ +import type { ApiGenericUserError } from "@/graphql/types"; +import type { CreateDiscountFormValues } from "../modals/create-discount-modal/schema"; + +export interface DiscountFormError { + field: keyof CreateDiscountFormValues; + message: string; +} + +export function mapDiscountUserErrorsToFormErrors( + errors: ApiGenericUserError[], +): DiscountFormError[] { + return errors.flatMap((error) => { + const path = error.field ?? []; + const field = path.at(-1); + + if (field !== "kind" && field !== "method" && field !== "title") return []; + return [{ field, message: error.message }]; + }); +} diff --git a/admin/src/domains/inventory/discounts/mappers/discount-general-update.mapper.ts b/admin/src/domains/inventory/discounts/mappers/discount-general-update.mapper.ts new file mode 100644 index 000000000..38b975864 --- /dev/null +++ b/admin/src/domains/inventory/discounts/mappers/discount-general-update.mapper.ts @@ -0,0 +1,146 @@ +import type { + ApiDiscount, + ApiDiscountUpdateInput, +} from "@/graphql/types"; +import { DiscountCodeStatus } from "@/graphql/types"; + +export interface DiscountCodeEditorRow { + key: string; + id?: string; + clientMutationId?: string; + code: string; + status: DiscountCodeStatus; + usageLimit: number | null; + updatedAt?: string; +} + +export interface DiscountGeneralFormValues { + title: string; + priority: number; + codes: DiscountCodeEditorRow[]; +} + +const normalizeUsageLimit = (value: number | null | undefined) => + value == null ? null : Number(value); + +export function createDiscountGeneralFormValues( + discount: ApiDiscount, +): DiscountGeneralFormValues { + return { + title: discount.title ?? "", + priority: discount.priority, + codes: discount.codes.edges.map(({ node }) => ({ + key: node.id, + id: node.id, + code: node.code, + status: node.status, + usageLimit: normalizeUsageLimit(node.usageLimit), + updatedAt: node.updatedAt, + })), + }; +} + +export function validateDiscountGeneralForm( + values: DiscountGeneralFormValues, +): string[] { + const errors: string[] = []; + const normalizedCodes = new Set(); + + if (!Number.isInteger(values.priority) || values.priority < 0) { + errors.push("Priority must be a non-negative whole number."); + } + + values.codes.forEach((row, index) => { + const code = row.code.trim(); + const normalizedCode = code.toLocaleUpperCase(); + + if (!code) { + errors.push(`Code ${index + 1} cannot be empty.`); + } else if (normalizedCodes.has(normalizedCode)) { + errors.push(`Code "${code}" is duplicated.`); + } else { + normalizedCodes.add(normalizedCode); + } + + if ( + row.usageLimit != null && + (!Number.isInteger(row.usageLimit) || row.usageLimit < 1) + ) { + errors.push( + `Usage limit for "${code || `code ${index + 1}`}" must be a positive whole number.`, + ); + } + }); + + return errors; +} + +export function buildDiscountGeneralUpdateInput( + discount: ApiDiscount, + values: DiscountGeneralFormValues, +): ApiDiscountUpdateInput { + const operations: ApiDiscountUpdateInput = {}; + const title = values.title.trim(); + const originalTitle = discount.title ?? ""; + const definition: NonNullable = {}; + + if (title !== originalTitle) { + definition.title = title || null; + } + if (values.priority !== discount.priority) { + definition.priority = values.priority; + } + if (Object.keys(definition).length > 0) { + operations.definition = definition; + } + + const originalRows = discount.codes.edges.map(({ node }) => node); + const originalById = new Map(originalRows.map((row) => [row.id, row])); + const currentIds = new Set( + values.codes.flatMap((row) => (row.id ? [row.id] : [])), + ); + + const create = values.codes + .filter((row) => !row.id) + .map((row) => ({ + clientMutationId: row.clientMutationId, + code: row.code.trim(), + ...(row.usageLimit == null ? {} : { usageLimit: row.usageLimit }), + })); + + const update = values.codes.flatMap((row) => { + if (!row.id || !row.updatedAt) return []; + const original = originalById.get(row.id); + if (!original) return []; + + const code = row.code.trim(); + const usageLimit = normalizeUsageLimit(row.usageLimit); + const originalUsageLimit = normalizeUsageLimit(original.usageLimit); + const operation = { + codeId: row.id, + expectedUpdatedAt: row.updatedAt, + ...(code === original.code ? {} : { code }), + ...(row.status === original.status ? {} : { status: row.status }), + ...(usageLimit === originalUsageLimit ? {} : { usageLimit }), + }; + + return Object.keys(operation).length > 2 ? [operation] : []; + }); + + const deleted = originalRows + .filter((row) => !currentIds.has(row.id)) + .map((row) => ({ + codeId: row.id, + expectedUpdatedAt: row.updatedAt, + })); + + if (create.length > 0 || update.length > 0 || deleted.length > 0) { + operations.codes = { + ...(create.length > 0 ? { create } : {}), + ...(update.length > 0 ? { update } : {}), + ...(deleted.length > 0 ? { delete: deleted } : {}), + }; + } + + return operations; +} diff --git a/admin/src/domains/inventory/discounts/mappers/discount-value-targets-update.mapper.ts b/admin/src/domains/inventory/discounts/mappers/discount-value-targets-update.mapper.ts new file mode 100644 index 000000000..b09d8f530 --- /dev/null +++ b/admin/src/domains/inventory/discounts/mappers/discount-value-targets-update.mapper.ts @@ -0,0 +1,436 @@ +import type { + ApiDiscount, + ApiDiscountUpdateInput, +} from "@/graphql/types"; +import { + DiscountAllocationMethod, + DiscountKind, + DiscountRequirementType, + DiscountTargetRole, + DiscountTargetType, + DiscountValueType, +} from "@/graphql/types"; + +export interface DiscountTargetEditorItem { + id: string; + title: string; +} + +export interface DiscountValueTargetsFormValues { + valueType: DiscountValueType.Percentage | DiscountValueType.FixedAmount; + percentage: number | null; + amount: string; + allocationMethod: DiscountAllocationMethod; + maximumDiscount: string; + maximumShippingPrice: string; + targetType: DiscountTargetType; + targets: DiscountTargetEditorItem[]; + qualifierTargetType: DiscountTargetType; + qualifierTargets: DiscountTargetEditorItem[]; + requirementType: DiscountRequirementType | null; + minimumSubtotal: string; + minimumQuantity: number | null; + buyRequirementType: DiscountRequirementType; + requiredSubtotal: string; + requiredQuantity: number | null; + benefitQuantity: number | null; + benefitValueType: DiscountValueType; + benefitPercentage: number | null; + benefitAmount: string; + usesPerOrderLimit: number | null; +} + +function minorToMajor(value: number | null | undefined): string { + if (value == null) return ""; + + const amount = BigInt(value); + const whole = amount / 100n; + const fraction = (amount % 100n).toString().padStart(2, "0"); + + return fraction === "00" + ? whole.toString() + : `${whole}.${fraction.replace(/0$/, "")}`; +} + +function majorToMinor(value: string): number | null { + const normalized = value.trim().replace(",", "."); + if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) return null; + + const [whole, fraction = ""] = normalized.split("."); + const amount = Number( + BigInt(whole) * 100n + + BigInt(fraction.padEnd(2, "0")), + ); + + return Number.isSafeInteger(amount) ? amount : null; +} + +function isPositiveMoney(value: string): boolean { + const amount = majorToMinor(value); + return amount != null && amount > 0; +} + +function getTargetTitle( + target: ApiDiscount["targetSelections"][number]["targets"][number], +): string { + const entity = target.target; + if (!entity) return target.targetId; + if (entity.__typename === "Product") return entity.title; + if (entity.__typename === "Category") return entity.name; + if (entity.__typename === "Variant") { + return [entity.product.title, entity.handle].filter(Boolean).join(" / "); + } + return target.targetId; +} + +function getSelection( + discount: ApiDiscount, + role: DiscountTargetRole, +) { + return ( + discount.targetSelections.find((selection) => selection.role === role) ?? + null + ); +} + +function mapTargets( + selection: ApiDiscount["targetSelections"][number] | null, +): DiscountTargetEditorItem[] { + return ( + selection?.targets.map((target) => ({ + id: target.targetId, + title: getTargetTitle(target), + })) ?? [] + ); +} + +export function createDiscountValueTargetsFormValues( + discount: ApiDiscount, +): DiscountValueTargetsFormValues { + const amountOff = + discount.rule?.__typename === "DiscountAmountOffRule" + ? discount.rule + : null; + const buyXGetY = + discount.rule?.__typename === "DiscountBuyXGetYRule" + ? discount.rule + : null; + const freeShipping = + discount.rule?.__typename === "DiscountFreeShippingRule" + ? discount.rule + : null; + const benefitSelection = getSelection( + discount, + DiscountTargetRole.Benefit, + ); + const qualifierSelection = getSelection( + discount, + DiscountTargetRole.Qualifier, + ); + const minimum = discount.minimumRequirement; + + return { + valueType: + amountOff?.valueType === DiscountValueType.FixedAmount + ? DiscountValueType.FixedAmount + : DiscountValueType.Percentage, + percentage: + amountOff?.percentageBps == null + ? null + : amountOff.percentageBps / 100, + amount: minorToMajor(amountOff?.amountMinor), + allocationMethod: + amountOff?.allocationMethod ?? DiscountAllocationMethod.Each, + maximumDiscount: minorToMajor(amountOff?.maximumDiscountMinor), + maximumShippingPrice: minorToMajor( + freeShipping?.maximumShippingPriceMinor, + ), + targetType: + benefitSelection?.targetType ?? DiscountTargetType.AllProducts, + targets: mapTargets(benefitSelection), + qualifierTargetType: + qualifierSelection?.targetType ?? DiscountTargetType.AllProducts, + qualifierTargets: mapTargets(qualifierSelection), + requirementType: minimum?.requirementType ?? null, + minimumSubtotal: minorToMajor(minimum?.subtotalMinor), + minimumQuantity: minimum?.quantity ?? null, + buyRequirementType: + buyXGetY?.requirementType ?? DiscountRequirementType.Quantity, + requiredSubtotal: minorToMajor(buyXGetY?.requiredSubtotalMinor), + requiredQuantity: buyXGetY?.requiredQuantity ?? 1, + benefitQuantity: buyXGetY?.benefitQuantity ?? 1, + benefitValueType: + buyXGetY?.benefitValueType ?? DiscountValueType.Free, + benefitPercentage: + buyXGetY?.benefitPercentageBps == null + ? null + : buyXGetY.benefitPercentageBps / 100, + benefitAmount: minorToMajor(buyXGetY?.benefitAmountMinor), + usesPerOrderLimit: buyXGetY?.usesPerOrderLimit ?? null, + }; +} + +function validateTargetSelection( + targetType: DiscountTargetType, + targets: DiscountTargetEditorItem[], + label: string, + errors: string[], +) { + if ( + targetType !== DiscountTargetType.AllProducts && + targets.length === 0 + ) { + errors.push(`Select at least one ${label} target.`); + } +} + +export function validateDiscountValueTargetsForm( + discount: ApiDiscount, + values: DiscountValueTargetsFormValues, +): string[] { + const errors: string[] = []; + + if ( + discount.kind === DiscountKind.AmountOffProducts || + discount.kind === DiscountKind.AmountOffOrder + ) { + if ( + values.valueType === DiscountValueType.Percentage && + (values.percentage == null || + values.percentage <= 0 || + values.percentage > 100) + ) { + errors.push("Percentage must be greater than 0 and no more than 100."); + } + if ( + values.valueType === DiscountValueType.FixedAmount && + !isPositiveMoney(values.amount) + ) { + errors.push("Fixed amount must be a positive amount."); + } + if ( + values.maximumDiscount && + !isPositiveMoney(values.maximumDiscount) + ) { + errors.push("Maximum discount must be a positive amount."); + } + } + + if (discount.kind === DiscountKind.AmountOffProducts) { + validateTargetSelection( + values.targetType, + values.targets, + "benefit", + errors, + ); + } + + if (discount.kind === DiscountKind.FreeShipping) { + if ( + values.maximumShippingPrice && + !isPositiveMoney(values.maximumShippingPrice) + ) { + errors.push("Maximum shipping price must be a positive amount."); + } + } + + if (discount.kind === DiscountKind.BuyXGetY) { + if ( + values.buyRequirementType === DiscountRequirementType.Quantity && + (!values.requiredQuantity || + !Number.isSafeInteger(values.requiredQuantity) || + values.requiredQuantity < 1) + ) { + errors.push("Required quantity must be a positive whole number."); + } + if ( + values.buyRequirementType === DiscountRequirementType.Subtotal && + !isPositiveMoney(values.requiredSubtotal) + ) { + errors.push("Required subtotal must be a positive amount."); + } + if ( + !values.benefitQuantity || + !Number.isSafeInteger(values.benefitQuantity) || + values.benefitQuantity < 1 + ) { + errors.push("Benefit quantity must be a positive whole number."); + } + if ( + values.benefitValueType === DiscountValueType.Percentage && + (values.benefitPercentage == null || + values.benefitPercentage <= 0 || + values.benefitPercentage > 100) + ) { + errors.push( + "Benefit percentage must be greater than 0 and no more than 100.", + ); + } + if ( + values.benefitValueType === DiscountValueType.FixedAmount && + !isPositiveMoney(values.benefitAmount) + ) { + errors.push("Benefit amount must be a positive amount."); + } + if ( + values.usesPerOrderLimit != null && + (!Number.isSafeInteger(values.usesPerOrderLimit) || + values.usesPerOrderLimit < 1) + ) { + errors.push("Uses per order limit must be a positive whole number."); + } + validateTargetSelection( + values.qualifierTargetType, + values.qualifierTargets, + "qualifier", + errors, + ); + validateTargetSelection( + values.targetType, + values.targets, + "benefit", + errors, + ); + } + + if ( + discount.kind !== DiscountKind.BuyXGetY && + values.requirementType === DiscountRequirementType.Subtotal && + !isPositiveMoney(values.minimumSubtotal) + ) { + errors.push("Minimum subtotal must be a positive amount."); + } + if ( + discount.kind !== DiscountKind.BuyXGetY && + values.requirementType === DiscountRequirementType.Quantity && + (!values.minimumQuantity || + !Number.isSafeInteger(values.minimumQuantity) || + values.minimumQuantity < 1) + ) { + errors.push("Minimum quantity must be a positive whole number."); + } + + return errors; +} + +function buildTargetSelection( + role: DiscountTargetRole, + targetType: DiscountTargetType, + targets: DiscountTargetEditorItem[], +) { + return { + role, + targetType, + targetIds: + targetType === DiscountTargetType.AllProducts + ? [] + : targets.map((target) => target.id), + }; +} + +export function buildDiscountValueTargetsUpdateInput( + discount: ApiDiscount, + values: DiscountValueTargetsFormValues, +): ApiDiscountUpdateInput { + const operations: ApiDiscountUpdateInput = {}; + + if ( + discount.kind === DiscountKind.AmountOffProducts || + discount.kind === DiscountKind.AmountOffOrder + ) { + operations.rule = { + amountOff: { + valueType: values.valueType, + allocationMethod: values.allocationMethod, + percentageBps: + values.valueType === DiscountValueType.Percentage + ? Math.round((values.percentage ?? 0) * 100) + : null, + amountMinor: + values.valueType === DiscountValueType.FixedAmount + ? majorToMinor(values.amount) + : null, + maximumDiscountMinor: values.maximumDiscount + ? majorToMinor(values.maximumDiscount) + : null, + }, + }; + operations.targetSelections = + discount.kind === DiscountKind.AmountOffProducts + ? [ + buildTargetSelection( + DiscountTargetRole.Benefit, + values.targetType, + values.targets, + ), + ] + : []; + } else if (discount.kind === DiscountKind.FreeShipping) { + operations.rule = { + freeShipping: { + maximumShippingPriceMinor: values.maximumShippingPrice + ? majorToMinor(values.maximumShippingPrice) + : null, + }, + }; + operations.targetSelections = []; + } else { + operations.rule = { + buyXGetY: { + requirementType: values.buyRequirementType, + requiredQuantity: + values.buyRequirementType === DiscountRequirementType.Quantity + ? values.requiredQuantity + : null, + requiredSubtotalMinor: + values.buyRequirementType === DiscountRequirementType.Subtotal + ? majorToMinor(values.requiredSubtotal) + : null, + benefitQuantity: values.benefitQuantity ?? 1, + benefitValueType: values.benefitValueType, + benefitPercentageBps: + values.benefitValueType === DiscountValueType.Percentage + ? Math.round((values.benefitPercentage ?? 0) * 100) + : null, + benefitAmountMinor: + values.benefitValueType === DiscountValueType.FixedAmount + ? majorToMinor(values.benefitAmount) + : null, + usesPerOrderLimit: values.usesPerOrderLimit, + }, + }; + operations.targetSelections = [ + buildTargetSelection( + DiscountTargetRole.Qualifier, + values.qualifierTargetType, + values.qualifierTargets, + ), + buildTargetSelection( + DiscountTargetRole.Benefit, + values.targetType, + values.targets, + ), + ]; + } + + if (discount.kind !== DiscountKind.BuyXGetY) { + operations.minimumRequirement = { + requirement: + values.requirementType == null + ? null + : { + requirementType: values.requirementType, + subtotalMinor: + values.requirementType === DiscountRequirementType.Subtotal + ? majorToMinor(values.minimumSubtotal) + : null, + quantity: + values.requirementType === DiscountRequirementType.Quantity + ? values.minimumQuantity + : null, + }, + }; + } + + return operations; +} diff --git a/admin/src/domains/inventory/discounts/mappers/index.ts b/admin/src/domains/inventory/discounts/mappers/index.ts new file mode 100644 index 000000000..3d937ab94 --- /dev/null +++ b/admin/src/domains/inventory/discounts/mappers/index.ts @@ -0,0 +1,34 @@ +export { buildDiscountCreateInput } from "./discount-create.mapper"; +export { + buildDiscountGeneralUpdateInput, + createDiscountGeneralFormValues, + validateDiscountGeneralForm, + type DiscountCodeEditorRow, + type DiscountGeneralFormValues, +} from "./discount-general-update.mapper"; +export { + buildDiscountValueTargetsUpdateInput, + createDiscountValueTargetsFormValues, + validateDiscountValueTargetsForm, + type DiscountTargetEditorItem, + type DiscountValueTargetsFormValues, +} from "./discount-value-targets-update.mapper"; +export { + buildDiscountEligibilityChannelsUpdateInput, + createDiscountEligibilityChannelsFormValues, + validateDiscountEligibilityChannelsForm, + type DiscountChannelEditorRow, + type DiscountCustomerEditorRow, + type DiscountEligibilityChannelsFormValues, + type DiscountSegmentEditorRow, +} from "./discount-eligibility-channels-update.mapper"; +export { + buildDiscountAvailabilityUpdateInput, + createDiscountAvailabilityFormValues, + validateDiscountAvailabilityForm, + type DiscountAvailabilityFormValues, +} from "./discount-availability-update.mapper"; +export { + mapDiscountUserErrorsToFormErrors, + type DiscountFormError, +} from "./discount-errors.mapper"; diff --git a/admin/src/domains/inventory/discounts/modals.ts b/admin/src/domains/inventory/discounts/modals.ts new file mode 100644 index 000000000..4c86f515d --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals.ts @@ -0,0 +1,76 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; +import type { ApiDiscount } from "@/graphql/types"; + +export const DISCOUNT_CREATE_MODAL_TYPE = "discount-create"; +export const DISCOUNT_MODAL_TYPE = "discount"; +export const DISCOUNT_GENERAL_EDIT_MODAL_TYPE = "discount-general-edit"; +export const DISCOUNT_VALUE_TARGETS_EDIT_MODAL_TYPE = + "discount-value-targets-edit"; +export const DISCOUNT_ELIGIBILITY_CHANNELS_EDIT_MODAL_TYPE = + "discount-eligibility-channels-edit"; +export const DISCOUNT_AVAILABILITY_EDIT_MODAL_TYPE = + "discount-availability-edit"; + +export interface IDiscountModalPayload extends IModalStackPayload { + entityId: string; +} + +export interface ICreateDiscountModalPayload extends IModalStackPayload { + onCreated?: (discount: ApiDiscount) => Promise | unknown; +} + +export interface IDiscountGeneralEditModalPayload extends IModalStackPayload { + discount: ApiDiscount; + onSaved?: () => Promise | unknown; +} + +export interface IDiscountValueTargetsEditModalPayload + extends IModalStackPayload { + discount: ApiDiscount; + onSaved?: () => Promise | unknown; +} + +export interface IDiscountEligibilityChannelsEditModalPayload + extends IModalStackPayload { + discount: ApiDiscount; + onSaved?: () => Promise | unknown; +} + +export interface IDiscountAvailabilityEditModalPayload + extends IModalStackPayload { + discount: ApiDiscount; + onSaved?: () => Promise | unknown; +} + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [DISCOUNT_MODAL_TYPE]: IDiscountModalPayload; + [DISCOUNT_CREATE_MODAL_TYPE]: ICreateDiscountModalPayload; + [DISCOUNT_GENERAL_EDIT_MODAL_TYPE]: IDiscountGeneralEditModalPayload; + [DISCOUNT_VALUE_TARGETS_EDIT_MODAL_TYPE]: IDiscountValueTargetsEditModalPayload; + [DISCOUNT_ELIGIBILITY_CHANNELS_EDIT_MODAL_TYPE]: IDiscountEligibilityChannelsEditModalPayload; + [DISCOUNT_AVAILABILITY_EDIT_MODAL_TYPE]: IDiscountAvailabilityEditModalPayload; + } +} + +export const useDiscountModal = createModalStackHook(DISCOUNT_MODAL_TYPE); + +export const useCreateDiscountModal = createModalStackHook( + DISCOUNT_CREATE_MODAL_TYPE, +); + +export const useDiscountGeneralEditModal = createModalStackHook( + DISCOUNT_GENERAL_EDIT_MODAL_TYPE, +); + +export const useDiscountValueTargetsEditModal = createModalStackHook( + DISCOUNT_VALUE_TARGETS_EDIT_MODAL_TYPE, +); + +export const useDiscountEligibilityChannelsEditModal = + createModalStackHook(DISCOUNT_ELIGIBILITY_CHANNELS_EDIT_MODAL_TYPE); + +export const useDiscountAvailabilityEditModal = createModalStackHook( + DISCOUNT_AVAILABILITY_EDIT_MODAL_TYPE, +); diff --git a/admin/src/domains/inventory/discounts/modals/create-discount-modal/create-discount-modal.tsx b/admin/src/domains/inventory/discounts/modals/create-discount-modal/create-discount-modal.tsx new file mode 100644 index 000000000..ec687141c --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/create-discount-modal/create-discount-modal.tsx @@ -0,0 +1,429 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { + Controller, + FormProvider, + useForm, + useWatch, +} from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, App, Input, Radio, Typography } from "antd"; +import { createStyles } from "antd-style"; +import { LuInfo as InfoOutlined } from "react-icons/lu"; +import { DiscountKind, DiscountMethod } from "@/graphql/types"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { useDefaultCurrency } from "@/domains/workspace/hooks"; +import { Paper } from "@/ui-kit/paper"; +import { useCreateDiscount } from "../../hooks"; +import { + buildDiscountCreateInput, + mapDiscountUserErrorsToFormErrors, +} from "../../mappers"; +import type { ICreateDiscountModalPayload } from "../../modals"; +import { DiscountTypeSelector } from "../select-discount-type-modal"; +import { + createDiscountSchema, + type CreateDiscountFormValues, +} from "./schema"; + +const useStyles = createStyles(({ token }) => ({ + container: { + display: "flex", + flexDirection: "column", + gap: 16, + paddingBlock: 8, + }, + section: { + display: "flex", + flexDirection: "column", + gap: 16, + padding: 16, + }, + sectionHeader: { + display: "flex", + flexDirection: "column", + gap: 12, + }, + sectionHeaderCopy: { + display: "flex", + flexDirection: "column", + gap: 4, + }, + sectionTitle: { + fontSize: 16, + fontWeight: 600, + color: token.colorText, + lineHeight: 1.35, + }, + sectionDescription: { + fontSize: 13, + color: token.colorTextTertiary, + lineHeight: 1.35, + }, + divider: { + height: 1, + background: token.colorBorderSecondary, + }, + field: { + display: "flex", + flexDirection: "column", + gap: 6, + }, + label: { + fontSize: 13, + fontWeight: 600, + color: token.colorText, + }, + labelRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 16, + }, + hint: { + fontSize: 11, + color: token.colorTextSecondary, + }, + methodGroup: { + width: "100%", + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: 12, + "@media (max-width: 700px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + methodOption: { + width: "100%", + minHeight: 56, + marginInlineEnd: 0, + padding: "12px 14px", + border: `1px solid ${token.colorBorder}`, + borderRadius: 10, + background: token.colorBgContainer, + alignItems: "center", + boxSizing: "border-box", + transition: `border-color ${token.motionDurationMid}, background ${token.motionDurationMid}, box-shadow ${token.motionDurationMid}`, + "&:hover": { + borderColor: token.colorPrimaryBorderHover, + }, + ".ant-radio + span": { + minWidth: 0, + flex: 1, + paddingInlineStart: 12, + paddingInlineEnd: 0, + }, + }, + methodOptionSelected: { + borderColor: token.colorPrimary, + background: token.colorPrimaryBg, + boxShadow: `inset 0 0 0 1px ${token.colorPrimary}`, + "&:hover": { + borderColor: token.colorPrimary, + }, + }, + methodCopy: { + display: "flex", + flexDirection: "column", + gap: 2, + }, + methodTitle: { + display: "block", + fontSize: 14, + fontWeight: 600, + color: token.colorText, + lineHeight: 1.35, + }, + methodDescription: { + display: "block", + fontSize: 11, + color: token.colorTextSecondary, + lineHeight: 1.35, + }, + error: { + fontSize: 12, + color: token.colorError, + }, + note: { + display: "flex", + alignItems: "center", + gap: 8, + padding: "12px 14px", + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: 10, + background: token.colorFillQuaternary, + }, + noteIcon: { + width: 28, + height: 28, + flex: "0 0 28px", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + background: token.colorBgContainer, + color: token.colorTextSecondary, + fontSize: 15, + }, + noteText: { + fontSize: 12, + color: token.colorTextSecondary, + }, +})); + +const DEFAULT_VALUES: CreateDiscountFormValues = { + kind: DiscountKind.AmountOffProducts, + method: DiscountMethod.Automatic, + title: "", +}; + +interface SectionHeaderProps { + description: string; + step: number; + title: string; +} + +function SectionHeader({ description, step, title }: SectionHeaderProps) { + const { styles } = useStyles(); + + return ( +
    +
    + + {step}  {title} + + + {description} + +
    +
    +
    + ); +} + +export function CreateDiscountModal() { + const { styles, cx } = useStyles(); + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const { onCreated } = payload as ICreateDiscountModalPayload; + const defaultCurrency = useDefaultCurrency(); + const { createDiscount, loading } = useCreateDiscount(); + + const methods = useForm({ + resolver: zodResolver(createDiscountSchema), + defaultValues: DEFAULT_VALUES, + }); + const { + control, + formState: { errors, isDirty }, + handleSubmit, + setValue, + setError, + } = methods; + const method = useWatch({ control, name: "method" }); + const kind = useWatch({ control, name: "kind" }); + + useEffect(() => { + setDirty(isDirty); + }, [isDirty, setDirty]); + + const onSubmit = useCallback( + async (values: CreateDiscountFormValues) => { + if (!defaultCurrency) return; + + const { discount, userErrors } = await createDiscount( + buildDiscountCreateInput({ + currency: defaultCurrency, + values, + }), + ); + + if (userErrors.length > 0) { + mapDiscountUserErrorsToFormErrors(userErrors).forEach((error) => { + setError(error.field, { message: error.message }); + }); + message.error(userErrors[0].message); + return; + } + + if (discount) { + message.success("Discount draft created successfully"); + await onCreated?.(discount); + setDirty(false); + forcePop(); + } + }, + [ + createDiscount, + defaultCurrency, + forcePop, + message, + onCreated, + setDirty, + setError, + ], + ); + + return ( + + + } + > +
    + {!defaultCurrency && ( + + )} + + + + { + setValue("kind", selectedKind, { + shouldDirty: true, + shouldValidate: true, + }); + }} + /> + {errors.kind?.message && ( + + {errors.kind.message} + + )} + + + + + +
    + ( + + + + + Discount code + + + Customers enter a code at checkout. + + + + + + + Automatic discount + + + Applied automatically when conditions match. + + + + + )} + /> + {errors.method?.message && ( + + {errors.method.message} + + )} +
    + + {method === DiscountMethod.Automatic && ( +
    +
    + + Title + + + Required for automatic discounts + +
    + ( + + )} + /> + {errors.title?.message && ( + + {errors.title.message} + + )} +
    + )} +
    + +
    + + + + + Value and conditions are configured in the next step. + +
    +
    +
    +
    + ); +} diff --git a/admin/src/domains/inventory/discounts/modals/create-discount-modal/index.ts b/admin/src/domains/inventory/discounts/modals/create-discount-modal/index.ts new file mode 100644 index 000000000..6665b0084 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/create-discount-modal/index.ts @@ -0,0 +1 @@ +export { CreateDiscountModal } from "./create-discount-modal"; diff --git a/admin/src/domains/inventory/discounts/modals/create-discount-modal/schema.ts b/admin/src/domains/inventory/discounts/modals/create-discount-modal/schema.ts new file mode 100644 index 000000000..ac69f6b8a --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/create-discount-modal/schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import { DiscountKind, DiscountMethod } from "@/graphql/types"; + +export const createDiscountSchema = z + .object({ + kind: z.enum( + [ + DiscountKind.AmountOffProducts, + DiscountKind.BuyXGetY, + DiscountKind.AmountOffOrder, + DiscountKind.FreeShipping, + ], + { error: "Select a discount type" }, + ), + method: z.enum([DiscountMethod.Automatic, DiscountMethod.Code], { + error: "Select an activation method", + }), + title: z.string().trim().max(255, "Title must be 255 characters or less"), + }) + .superRefine((values, context) => { + if (values.method === DiscountMethod.Automatic && !values.title) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["title"], + message: "Title is required for automatic discounts", + }); + } + }); + +export type CreateDiscountFormValues = z.infer; diff --git a/admin/src/domains/inventory/discounts/modals/discount-modal/discount-modal.styles.ts b/admin/src/domains/inventory/discounts/modals/discount-modal/discount-modal.styles.ts new file mode 100644 index 000000000..257630e0e --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/discount-modal/discount-modal.styles.ts @@ -0,0 +1,12 @@ +import { createStyles } from "antd-style"; + +export const useDiscountModalStyles = createStyles(({ token }) => ({ + body: { + background: token.colorFillQuaternary, + }, + state: { + width: "100%", + marginInline: "auto", + padding: token.padding, + }, +})); diff --git a/admin/src/domains/inventory/discounts/modals/discount-modal/discount-modal.tsx b/admin/src/domains/inventory/discounts/modals/discount-modal/discount-modal.tsx new file mode 100644 index 000000000..61404eed7 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/discount-modal/discount-modal.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback } from "react"; +import { Alert, Empty, Flex, Skeleton } from "antd"; +import { ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { DiscountDetailsCard } from "../../components/discount-details-card"; +import { DiscountStatusTag } from "../../components/discount-details-card/discount-status-tag"; +import type { DiscountDetailsSection } from "../../components/discount-details-card/types"; +import { useDiscount } from "../../hooks"; +import { + useDiscountAvailabilityEditModal, + useDiscountEligibilityChannelsEditModal, + useDiscountGeneralEditModal, + useDiscountValueTargetsEditModal, + type IDiscountModalPayload, +} from "../../modals"; +import { DiscountMethod } from "@/graphql/types"; +import { useDiscountModalStyles } from "./discount-modal.styles"; + +export function DiscountModal() { + const { payload, forcePop } = useModalStackContext(); + const { styles } = useDiscountModalStyles(); + const typedPayload = payload as IDiscountModalPayload; + const entityId = + typeof typedPayload.entityId === "string" ? typedPayload.entityId : null; + const { discount, loading, error, refetch } = useDiscount(entityId); + const { push: openGeneralSettingsModal } = useDiscountGeneralEditModal(); + const { push: openValueTargetsModal } = + useDiscountValueTargetsEditModal(); + const { push: openEligibilityChannelsModal } = + useDiscountEligibilityChannelsEditModal(); + const { push: openAvailabilityModal } = + useDiscountAvailabilityEditModal(); + + const editValueTargets = useCallback(() => { + if (!discount) { + return; + } + + openValueTargetsModal({ + discount, + onSaved: refetch, + }); + }, [discount, openValueTargetsModal, refetch]); + + const editGeneralSettings = useCallback( + (section: DiscountDetailsSection) => { + if (!discount) { + return; + } + + if ( + section === "summary" || + (section === "codes" && discount.method === DiscountMethod.Code) + ) { + openGeneralSettingsModal({ + discount, + onSaved: refetch, + }); + return; + } + + if (section === "eligibility" || section === "channels") { + openEligibilityChannelsModal({ + discount, + onSaved: refetch, + }); + return; + } + + if (section === "combinations" || section === "availability") { + openAvailabilityModal({ + discount, + onSaved: refetch, + }); + } + }, + [ + discount, + openAvailabilityModal, + openEligibilityChannelsModal, + openGeneralSettingsModal, + refetch, + ], + ); + + const renderContent = () => { + if (loading && !discount) { + return ( +
    + +
    + ); + } + + if (error) { + return ( +
    + +
    + ); + } + + if (!discount) { + return ( + + + + ); + } + + return ( + + ); + }; + + return ( + + ) : null, + }} + bodyClassName={styles.body} + > + {renderContent()} + + ); +} diff --git a/admin/src/domains/inventory/discounts/modals/discount-modal/index.ts b/admin/src/domains/inventory/discounts/modals/discount-modal/index.ts new file mode 100644 index 000000000..64229554c --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/discount-modal/index.ts @@ -0,0 +1 @@ +export { DiscountModal } from "./discount-modal"; diff --git a/admin/src/domains/inventory/discounts/modals/edit-availability-modal/edit-availability-modal.styles.ts b/admin/src/domains/inventory/discounts/modals/edit-availability-modal/edit-availability-modal.styles.ts new file mode 100644 index 000000000..b38442fdd --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-availability-modal/edit-availability-modal.styles.ts @@ -0,0 +1,66 @@ +import { createStyles } from "antd-style"; + +export const useEditAvailabilityModalStyles = createStyles(({ token }) => ({ + body: { + background: token.colorFillQuaternary, + }, + container: { + width: "100%", + maxWidth: 1050, + marginInline: "auto", + display: "flex", + flexDirection: "column", + gap: token.margin, + padding: token.padding, + boxSizing: "border-box", + }, + section: { + display: "flex", + flexDirection: "column", + gap: token.marginSM, + }, + optionList: { + display: "flex", + flexDirection: "column", + gap: token.marginSM, + }, + option: { + display: "flex", + alignItems: "flex-start", + }, + optionCopy: { + display: "flex", + flexDirection: "column", + }, + optionHelp: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + field: { + minWidth: 0, + }, + fieldGrid: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", + gap: token.margin, + "@media (max-width: 720px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + fieldLabel: { + display: "block", + marginBottom: token.marginXXS, + }, + fieldHelp: { + display: "block", + marginTop: token.marginXXS, + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + info: { + padding: `${token.paddingSM}px ${token.padding}px`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + color: token.colorTextSecondary, + }, +})); diff --git a/admin/src/domains/inventory/discounts/modals/edit-availability-modal/edit-availability-modal.tsx b/admin/src/domains/inventory/discounts/modals/edit-availability-modal/edit-availability-modal.tsx new file mode 100644 index 000000000..1edb0d9d3 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-availability-modal/edit-availability-modal.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { + useCallback, + useEffect, + useState, +} from "react"; +import { + Alert, + App, + Checkbox, + Flex, + Input, + InputNumber, + Typography, +} from "antd"; +import { LuInfo } from "react-icons/lu"; +import { + DiscountClass, + DiscountMethod, +} from "@/graphql/types"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useUpdateDiscount } from "../../hooks"; +import { + buildDiscountAvailabilityUpdateInput, + createDiscountAvailabilityFormValues, + validateDiscountAvailabilityForm, + type DiscountAvailabilityFormValues, +} from "../../mappers"; +import type { + IDiscountAvailabilityEditModalPayload, +} from "../../modals"; +import { useEditAvailabilityModalStyles } from "./edit-availability-modal.styles"; + +const COMBINATIONS = [ + { + value: DiscountClass.Product, + title: "Product discounts", + description: "Allow combining with other product-level discounts.", + }, + { + value: DiscountClass.Order, + title: "Order discounts", + description: "Allow combining with order-level discounts.", + }, + { + value: DiscountClass.Shipping, + title: "Shipping discounts", + description: "Allow combining with shipping discounts.", + }, +] as const; + +const serializeValues = (values: DiscountAvailabilityFormValues) => + JSON.stringify({ + ...values, + combinesWith: [...values.combinesWith].sort(), + }); + +export function EditAvailabilityModal() { + const { styles } = useEditAvailabilityModalStyles(); + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const { discount, onSaved } = + payload as IDiscountAvailabilityEditModalPayload; + const mutation = useUpdateDiscount(); + const [values, setValues] = useState(() => + createDiscountAvailabilityFormValues(discount), + ); + const [initialSnapshot] = useState(() => + serializeValues(createDiscountAvailabilityFormValues(discount)), + ); + const [formError, setFormError] = useState(null); + const dirty = serializeValues(values) !== initialSnapshot; + const enabledCombinations = values.combinesWith.length; + const timezone = + Intl.DateTimeFormat().resolvedOptions().timeZone || "Local time"; + const isCodeDiscount = discount.method === DiscountMethod.Code; + + useEffect(() => { + setDirty(dirty); + }, [dirty, setDirty]); + + const updateValues = useCallback( + (changes: Partial) => { + setValues((current) => ({ ...current, ...changes })); + setFormError(null); + }, + [], + ); + + const toggleCombination = useCallback( + (discountClass: DiscountClass, enabled: boolean) => { + updateValues({ + combinesWith: enabled + ? [...new Set([...values.combinesWith, discountClass])] + : values.combinesWith.filter( + (item) => item !== discountClass, + ), + }); + }, + [updateValues, values.combinesWith], + ); + + const save = useCallback(async () => { + const validationErrors = validateDiscountAvailabilityForm( + discount, + values, + ); + if (validationErrors.length > 0) { + setFormError(validationErrors.join(" ")); + return; + } + + const result = await mutation.updateDiscount({ + discountId: discount.id, + expectedRevision: discount.revision, + operations: buildDiscountAvailabilityUpdateInput(discount, values), + }); + + if (!result.discount || result.errors.length > 0) { + setFormError( + result.errors.map((error) => error.message).join(" ") || + "Unable to update availability.", + ); + return; + } + + setDirty(false); + message.success("Availability, limits and combinations updated"); + forcePop(); + if (onSaved) { + void Promise.resolve(onSaved()).catch(() => { + message.error("Discount saved, but the details could not be refreshed"); + }); + } + }, [ + discount, + forcePop, + message, + mutation, + onSaved, + setDirty, + values, + ]); + + const errorMessage = formError ?? mutation.error?.message ?? null; + + return ( + + } + bodyClassName={styles.body} + > +
    + {errorMessage ? ( + + ) : null} + + + + At least one required + + } + /> +
    + + updateValues({ + appliesOnOneTimePurchase: event.target.checked, + }) + } + > + + One-time purchase + + appliesOnOneTimePurchase:{" "} + {String(values.appliesOnOneTimePurchase)} + + + + + updateValues({ + appliesOnSubscription: event.target.checked, + }) + } + > + + Subscription + + appliesOnSubscription:{" "} + {String(values.appliesOnSubscription)} + + + +
    +
    + + + +
    + + Total usage limit + + updateValues({ usageLimit })} + style={{ width: "100%" }} + /> + + Optional usageLimit; must not be below reserved and consumed + usage. + +
    + + updateValues({ + appliesOncePerCustomer: event.target.checked, + }) + } + > + + Limit to one use per customer + + appliesOncePerCustomer. Available for CODE discounts only. + + + +
    + + + + {enabledCombinations} of {COMBINATIONS.length} enabled + + } + /> +
    + {COMBINATIONS.map((option) => ( + + toggleCombination(option.value, event.target.checked) + } + > + + {option.title} + + {option.description} + + + + ))} +
    + + + + Checked rows are submitted as the combinesWith DiscountClass + array. + + +
    + + + {timezone} + } + /> +
    +
    + + Starts at * + + + updateValues({ startsAt: event.target.value }) + } + /> + + Required DateTime. + +
    +
    + + Ends at + + + updateValues({ endsAt: event.target.value }) + } + /> + + Optional DateTime; must be after startsAt. + +
    +
    +
    +
    +
    + ); +} diff --git a/admin/src/domains/inventory/discounts/modals/edit-availability-modal/index.ts b/admin/src/domains/inventory/discounts/modals/edit-availability-modal/index.ts new file mode 100644 index 000000000..bf4db1174 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-availability-modal/index.ts @@ -0,0 +1 @@ +export { EditAvailabilityModal } from "./edit-availability-modal"; diff --git a/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/edit-eligibility-channels-modal.styles.ts b/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/edit-eligibility-channels-modal.styles.ts new file mode 100644 index 000000000..4842974f0 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/edit-eligibility-channels-modal.styles.ts @@ -0,0 +1,125 @@ +import { createStyles } from "antd-style"; + +export const useEditEligibilityChannelsModalStyles = createStyles( + ({ token }) => ({ + body: { + background: token.colorFillQuaternary, + }, + container: { + width: "100%", + maxWidth: 1050, + marginInline: "auto", + display: "flex", + flexDirection: "column", + gap: token.margin, + padding: token.padding, + boxSizing: "border-box", + }, + section: { + display: "flex", + flexDirection: "column", + gap: token.marginSM, + }, + contextDescription: { + padding: `${token.paddingSM}px ${token.padding}px`, + borderRadius: token.borderRadiusLG, + background: token.colorFillQuaternary, + }, + fieldLabel: { + display: "block", + marginBottom: token.marginXXS, + }, + fieldHelp: { + display: "block", + marginTop: token.marginXXS, + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + pickerRow: { + width: "100%", + }, + pickerSummary: { + flex: 1, + }, + selectedPaper: { + padding: 0, + overflow: "hidden", + }, + selectedHeader: { + minHeight: 52, + marginBottom: 0, + paddingInline: token.padding, + paddingBlock: token.paddingSM, + }, + selectedFields: { + padding: `${token.paddingSM}px ${token.padding}px 0`, + }, + gridFrame: { + margin: `${token.marginSM}px ${token.margin}px 0`, + overflow: "hidden", + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadiusLG, + "& .ag-root-wrapper": { + border: 0, + borderRadius: 0, + }, + "& .ag-header": { + borderBottomColor: token.colorBorderSecondary, + }, + "& .ag-header-cell": { + paddingInline: token.paddingSM, + fontSize: token.fontSizeSM, + fontWeight: token.fontWeightStrong, + }, + "& .ag-cell": { + display: "flex", + alignItems: "center", + paddingInline: token.paddingSM, + }, + }, + entityCell: { + minWidth: 0, + display: "flex", + alignItems: "center", + gap: token.marginSM, + }, + entityTitle: { + minWidth: 0, + }, + actionCell: { + width: "100%", + display: "flex", + justifyContent: "center", + }, + selectedFooter: { + padding: `${token.paddingSM}px ${token.padding}px ${token.padding}px`, + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + channelRow: { + minHeight: 54, + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) 140px", + alignItems: "center", + gap: token.margin, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + "&:last-child": { + borderBottom: 0, + }, + }, + channelCopy: { + minWidth: 0, + }, + channelCode: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + info: { + padding: token.padding, + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + background: token.colorBgContainer, + color: token.colorTextSecondary, + }, + }), +); diff --git a/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/edit-eligibility-channels-modal.tsx b/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/edit-eligibility-channels-modal.tsx new file mode 100644 index 000000000..a68d87135 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/edit-eligibility-channels-modal.tsx @@ -0,0 +1,600 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { + Alert, + App, + Avatar, + Button, + Checkbox, + Flex, + Input, + Segmented, + Typography, +} from "antd"; +import { + AllCommunityModule, + ModuleRegistry, + type ColDef, +} from "ag-grid-community"; +import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react"; +import { + LuInfo, + LuListPlus, + LuTrash2, +} from "react-icons/lu"; +import { DiscountBuyerContextType } from "@/graphql/types"; +import { useAgGridTheme } from "@/hooks"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { + useEntityPicker, +} from "@/shared/components/entity-picker-modal"; +import type { + IPickableEntity, +} from "@/shared/components/entity-picker-modal/types"; +import "@/domains/customers/all-customers/picker/customer-picker-config"; +import type { + CustomerSegmentPickerEntity, +} from "@/domains/customers/segments/picker"; +import "@/domains/customers/segments/picker/customer-segment-picker-config"; +import { useCustomerSegments } from "@/domains/customers/segments/hooks"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useUpdateDiscount } from "../../hooks"; +import { + buildDiscountEligibilityChannelsUpdateInput, + createDiscountEligibilityChannelsFormValues, + validateDiscountEligibilityChannelsForm, + type DiscountEligibilityChannelsFormValues, +} from "../../mappers"; +import type { + IDiscountEligibilityChannelsEditModalPayload, +} from "../../modals"; +import { + useEditEligibilityChannelsModalStyles, +} from "./edit-eligibility-channels-modal.styles"; + +ModuleRegistry.registerModules([AllCommunityModule]); + +interface CustomerPickerEntity extends IPickableEntity { + email?: string; +} + +interface SelectedEntityRow { + id: string; + title: string; + secondary: string; + customersCount: number | null; +} + +interface GridContext { + remove: (id: string) => void; +} + +function initials(title: string) { + return title + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); +} + +function EntityCell({ + data, +}: CustomCellRendererProps) { + const { styles } = useEditEligibilityChannelsModalStyles(); + if (!data) return null; + + return ( +
    + {initials(data.title)} + + {data.title} + +
    + ); +} + +function RemoveCell( + params: CustomCellRendererProps, +) { + const { styles } = useEditEligibilityChannelsModalStyles(); + if (!params.data) return null; + const context = params.context as GridContext; + + return ( +
    +
    + ); +} + +const serializeValues = ( + values: DiscountEligibilityChannelsFormValues, +) => + JSON.stringify({ + buyerContextType: values.buyerContextType, + customers: values.customers.map(({ id }) => id), + segments: values.segments.map(({ id }) => id), + channels: values.channels.map(({ code, enabled, featured }) => ({ + code, + enabled, + featured: enabled && featured, + })), + }); + +export function EditEligibilityChannelsModal() { + const { styles } = useEditEligibilityChannelsModalStyles(); + const { message } = App.useApp(); + const agGridTheme = useAgGridTheme(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const { discount, onSaved } = + payload as IDiscountEligibilityChannelsEditModalPayload; + const mutation = useUpdateDiscount(); + const [values, setValues] = useState(() => + createDiscountEligibilityChannelsFormValues(discount), + ); + const [initialSnapshot] = useState(() => + serializeValues( + createDiscountEligibilityChannelsFormValues(discount), + ), + ); + const [formError, setFormError] = useState(null); + const dirty = serializeValues(values) !== initialSnapshot; + const isCustomers = + values.buyerContextType === DiscountBuyerContextType.Customers; + const isSegments = + values.buyerContextType === DiscountBuyerContextType.Segments; + const selectedSegmentIds = values.segments.map(({ id }) => id); + const { segments: resolvedSegments } = useCustomerSegments({ + first: Math.max(selectedSegmentIds.length, 1), + where: { id: { _in: selectedSegmentIds } }, + }); + const resolvedSegmentById = useMemo( + () => new Map(resolvedSegments.map((segment) => [segment.id, segment])), + [resolvedSegments], + ); + + useEffect(() => { + setDirty(dirty); + }, [dirty, setDirty]); + + const updateValues = useCallback( + (changes: Partial) => { + setValues((current) => ({ ...current, ...changes })); + setFormError(null); + }, + [], + ); + + const customerPicker = useEntityPicker({ + entityType: "customer", + selectionMode: "multi", + allowEmptySelection: true, + initialSelection: values.customers.map(({ id }) => id), + onConfirm: (entities, ids) => { + const current = new Map( + values.customers.map((item) => [item.id, item]), + ); + entities.forEach((entity) => + current.set(entity.id, { + id: entity.id, + title: entity.title, + email: entity.email ?? "", + }), + ); + updateValues({ + customers: ids.map( + (id) => + current.get(id) ?? { id, title: id, email: "" }, + ), + }); + }, + }); + + const segmentPicker = useEntityPicker({ + entityType: "customer-segment", + selectionMode: "multi", + allowEmptySelection: true, + initialSelection: values.segments.map(({ id }) => id), + onConfirm: (entities, ids) => { + const current = new Map( + values.segments.map((item) => [item.id, item]), + ); + entities.forEach((entity) => + current.set(entity.id, { + id: entity.id, + title: entity.title, + customersCount: entity.customersCount, + }), + ); + updateValues({ + segments: ids.map( + (id) => + current.get(id) ?? { + id, + title: id, + customersCount: null, + }, + ), + }); + }, + }); + + const rows = useMemo( + () => + isCustomers + ? values.customers.map((customer) => ({ + id: customer.id, + title: customer.title, + secondary: customer.email, + customersCount: null, + })) + : values.segments.map((segment) => ({ + id: segment.id, + title: + resolvedSegmentById.get(segment.id)?.name ?? segment.title, + secondary: "", + customersCount: + resolvedSegmentById.get(segment.id)?.customersCount ?? + segment.customersCount, + })), + [ + isCustomers, + resolvedSegmentById, + values.customers, + values.segments, + ], + ); + + const remove = useCallback( + (id: string) => { + if (isCustomers) { + updateValues({ + customers: values.customers.filter((item) => item.id !== id), + }); + } else { + updateValues({ + segments: values.segments.filter((item) => item.id !== id), + }); + } + }, + [ + isCustomers, + updateValues, + values.customers, + values.segments, + ], + ); + const gridContext = useMemo(() => ({ remove }), [remove]); + const columnDefs = useMemo[]>( + () => [ + { + headerName: isCustomers ? "Customer" : "Segment", + field: "title", + flex: 1, + minWidth: 260, + cellRenderer: EntityCell, + }, + ...(isCustomers + ? [ + { + headerName: "Email", + field: "secondary" as const, + flex: 1, + minWidth: 240, + }, + ] + : [ + { + headerName: "Members", + field: "customersCount" as const, + flex: 1, + minWidth: 160, + valueFormatter: ({ value }: { value: number | null }) => + value == null ? "—" : String(value), + }, + ]), + { + headerName: "", + colId: "actions", + width: 52, + cellRenderer: RemoveCell, + }, + ], + [isCustomers], + ); + + const enabledChannels = values.channels.filter( + (channel) => channel.enabled, + ); + const featuredChannels = enabledChannels.filter( + (channel) => channel.featured, + ); + + const save = useCallback(async () => { + const validationErrors = + validateDiscountEligibilityChannelsForm(values); + if (validationErrors.length > 0) { + setFormError(validationErrors.join(" ")); + return; + } + + const result = await mutation.updateDiscount({ + discountId: discount.id, + expectedRevision: discount.revision, + operations: + buildDiscountEligibilityChannelsUpdateInput(values), + }); + + if (!result.discount || result.errors.length > 0) { + setFormError( + result.errors.map((error) => error.message).join(" ") || + "Unable to update eligibility and channels.", + ); + return; + } + + setDirty(false); + message.success("Eligibility and channels updated"); + forcePop(); + if (onSaved) { + void Promise.resolve(onSaved()).catch(() => { + message.error("Discount saved, but the details could not be refreshed"); + }); + } + }, [ + discount, + forcePop, + message, + mutation, + onSaved, + setDirty, + values, + ]); + + const contextCopy = { + [DiscountBuyerContextType.All]: { + title: "All customers", + description: "Every customer can redeem this discount.", + }, + [DiscountBuyerContextType.Customers]: { + title: "Specific customers", + description: + "Only the selected customer accounts can redeem this discount.", + }, + [DiscountBuyerContextType.Segments]: { + title: "Customer segments", + description: + "Customers in the selected segments can redeem this discount.", + }, + }[values.buyerContextType]; + const errorMessage = formError ?? mutation.error?.message ?? null; + + return ( + + } + bodyClassName={styles.body} + > +
    + {errorMessage ? ( + + ) : null} + + + + + updateValues({ + buyerContextType: + buyerContextType as DiscountBuyerContextType, + }) + } + /> + + {contextCopy.title} + + {contextCopy.description} + + + + + {isCustomers || isSegments ? ( + + + {rows.length} selected + + } + /> +
    + + {isCustomers ? "Customers" : "Segments"} + + + 0 + ? `${rows.length} ${ + isCustomers ? "customers" : "segments" + } selected` + : "" + } + placeholder={`No ${ + isCustomers ? "customers" : "segments" + } selected`} + /> + + + + Select {isCustomers ? "customers" : "segments"}, then review + or remove them in the grid below. + +
    +
    + + theme={agGridTheme} + rowData={rows} + columnDefs={columnDefs} + context={gridContext} + defaultColDef={{ + sortable: false, + resizable: false, + suppressHeaderMenuButton: true, + }} + getRowId={({ data }) => data.id} + domLayout="autoHeight" + headerHeight={40} + rowHeight={56} + suppressCellFocus + suppressMovableColumns + overlayNoRowsTemplate={`No ${ + isCustomers ? "customers" : "segments" + } selected`} + /> +
    + + {isCustomers + ? "Customers are stored by account ID." + : "Segments are evaluated from current membership at redemption time."} + +
    + ) : null} + + + + {enabledChannels.length} channel + {enabledChannels.length === 1 ? "" : "s"} ·{" "} + {featuredChannels.length} featured + + } + /> +
    + {values.channels.map((channel) => ( +
    + + updateValues({ + channels: values.channels.map((item) => + item.code === channel.code + ? { + ...item, + enabled: event.target.checked, + featured: event.target.checked + ? item.featured + : false, + } + : item, + ), + }) + } + > + + {channel.title} + + {channel.code} + + + + + updateValues({ + channels: values.channels.map((item) => + item.code === channel.code + ? { ...item, featured: event.target.checked } + : item, + ), + }) + } + > + Featured + +
    + ))} +
    +
    + + + + + Featured is configured per enabled channel and only affects + promotional placement. + + +
    +
    + ); +} diff --git a/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/index.ts b/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/index.ts new file mode 100644 index 000000000..67831d10f --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-eligibility-channels-modal/index.ts @@ -0,0 +1 @@ +export { EditEligibilityChannelsModal } from "./edit-eligibility-channels-modal"; diff --git a/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/edit-general-settings-modal.styles.ts b/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/edit-general-settings-modal.styles.ts new file mode 100644 index 000000000..75f23a7d2 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/edit-general-settings-modal.styles.ts @@ -0,0 +1,89 @@ +import { createStyles } from "antd-style"; + +export const useEditGeneralSettingsModalStyles = createStyles(({ token }) => ({ + body: { + background: token.colorFillQuaternary, + }, + container: { + display: "flex", + flexDirection: "column", + gap: token.margin, + padding: token.padding, + }, + definitionGrid: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", + gap: token.margin, + "@media (max-width: 720px)": { + gridTemplateColumns: "1fr", + }, + }, + field: { + minWidth: 0, + }, + fieldLabel: { + display: "block", + marginBottom: token.marginXXS, + }, + fieldHelp: { + display: "block", + marginTop: token.marginXXS, + fontSize: token.fontSizeSM, + }, + codesPaper: { + padding: 0, + overflow: "hidden", + }, + codesHeader: { + minHeight: 52, + marginBottom: 0, + paddingInline: token.padding, + paddingBlock: token.paddingSM, + }, + gridFrame: { + margin: `${token.marginSM}px ${token.margin}px 0`, + overflow: "hidden", + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadiusLG, + "& .ag-root-wrapper": { + border: 0, + borderRadius: 0, + }, + "& .ag-header": { + borderBottomColor: token.colorBorderSecondary, + }, + "& .ag-header-cell": { + paddingInline: token.paddingSM, + fontSize: token.fontSizeSM, + fontWeight: token.fontWeightStrong, + }, + "& .ag-cell": { + display: "flex", + alignItems: "center", + paddingInline: token.paddingXS, + }, + "& .ag-cell-last-left-pinned, & .ag-cell-last-right-pinned": { + border: 0, + }, + "& .ag-row:last-child": { + borderBottom: 0, + }, + "& .ant-input, & .ant-input-number, & .ant-select-selector": { + minHeight: 32, + }, + }, + cellControl: { + width: "100%", + }, + actionCell: { + width: "100%", + display: "flex", + justifyContent: "center", + }, + footer: { + padding: `${token.paddingSM}px ${token.padding}px ${token.padding}px`, + }, + footerText: { + fontSize: token.fontSizeSM, + }, +})); diff --git a/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/edit-general-settings-modal.tsx b/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/edit-general-settings-modal.tsx new file mode 100644 index 000000000..0cbbe5138 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/edit-general-settings-modal.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { + Alert, + App, + Button, + Dropdown, + Flex, + Input, + InputNumber, + Select, + Typography, +} from "antd"; +import { + AllCommunityModule, + ModuleRegistry, + type ColDef, +} from "ag-grid-community"; +import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react"; +import { + LuEllipsis as MoreOutlined, + LuPlus as PlusOutlined, + LuTrash2 as DeleteOutlined, +} from "react-icons/lu"; +import { useAgGridTheme } from "@/hooks"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { DiscountCodeStatus, DiscountMethod } from "@/graphql/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useUpdateDiscount } from "../../hooks"; +import { + buildDiscountGeneralUpdateInput, + createDiscountGeneralFormValues, + validateDiscountGeneralForm, + type DiscountCodeEditorRow, + type DiscountGeneralFormValues, +} from "../../mappers"; +import type { IDiscountGeneralEditModalPayload } from "../../modals"; +import { useEditGeneralSettingsModalStyles } from "./edit-general-settings-modal.styles"; + +ModuleRegistry.registerModules([AllCommunityModule]); + +interface GridContext { + updateRow: ( + key: string, + changes: Partial, + ) => void; + removeRow: (key: string) => void; +} + +function getGridContext( + params: CustomCellRendererProps, +): GridContext { + return params.context as GridContext; +} + +function CodeCell(params: CustomCellRendererProps) { + const { styles } = useEditGeneralSettingsModalStyles(); + if (!params.data) return null; + + return ( + + getGridContext(params).updateRow(params.data!.key, { + code: event.target.value, + }) + } + /> + ); +} + +function StatusCell(params: CustomCellRendererProps) { + const { styles } = useEditGeneralSettingsModalStyles(); + if (!params.data) return null; + + return ( + { + setValues((current) => ({ + ...current, + title: event.target.value, + })); + setFormError(null); + }} + /> +
    +
    + + Priority + + { + setValues((current) => ({ + ...current, + priority: priority ?? 0, + })); + setFormError(null); + }} + /> + + Higher values are evaluated first. + +
    +
    + + + {isCodeDiscount ? ( + + } + onClick={addCode} + data-testid="discount-code-add-button" + > + Add + + } + /> +
    + + theme={agGridTheme} + rowData={values.codes} + columnDefs={columnDefs} + context={gridContext} + defaultColDef={{ + sortable: false, + resizable: false, + suppressHeaderMenuButton: true, + }} + getRowId={({ data }) => data.key} + domLayout="autoHeight" + headerHeight={40} + rowHeight={52} + suppressCellFocus + suppressMovableColumns + overlayNoRowsTemplate="No discount codes" + /> +
    + + + Shown only for code-based discounts. Create, update and delete + operations are submitted with this form. + + + {values.codes.length}{" "} + {values.codes.length === 1 ? "code" : "codes"} + + +
    + ) : null} +
    + + ); +} diff --git a/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/index.ts b/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/index.ts new file mode 100644 index 000000000..797c2cb66 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-general-settings-modal/index.ts @@ -0,0 +1 @@ +export { EditGeneralSettingsModal } from "./edit-general-settings-modal"; diff --git a/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/edit-value-targets-modal.styles.ts b/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/edit-value-targets-modal.styles.ts new file mode 100644 index 000000000..d0978cbb0 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/edit-value-targets-modal.styles.ts @@ -0,0 +1,116 @@ +import { createStyles } from "antd-style"; + +export const useEditValueTargetsModalStyles = createStyles(({ token }) => ({ + body: { + background: token.colorFillQuaternary, + }, + container: { + width: "100%", + maxWidth: 1050, + marginInline: "auto", + display: "flex", + flexDirection: "column", + gap: token.margin, + padding: token.padding, + boxSizing: "border-box", + }, + section: { + display: "flex", + flexDirection: "column", + gap: token.marginSM, + }, + fieldGrid: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", + gap: token.margin, + "@media (max-width: 720px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + field: { + minWidth: 0, + }, + fieldLabel: { + display: "block", + marginBottom: token.marginXXS, + }, + fieldHelp: { + display: "block", + marginTop: token.marginXXS, + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + lineHeight: 1.35, + }, + allocation: { + alignItems: "flex-start", + }, + allocationCopy: { + display: "flex", + flexDirection: "column", + }, + targetGroup: { + width: "100%", + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: token.marginSM, + "@media (max-width: 720px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + targetOption: { + display: "flex", + width: "100%", + minHeight: 72, + marginInlineEnd: 0, + padding: `${token.paddingSM}px ${token.padding}px`, + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadiusLG, + background: token.colorBgContainer, + alignItems: "center", + boxSizing: "border-box", + transition: `border-color ${token.motionDurationMid}, background ${token.motionDurationMid}, box-shadow ${token.motionDurationMid}`, + "&:hover": { + borderColor: token.colorPrimaryBorderHover, + }, + ".ant-radio + span": { + minWidth: 0, + flex: 1, + paddingInlineStart: token.paddingSM, + paddingInlineEnd: 0, + }, + }, + targetOptionSelected: { + borderColor: token.colorPrimary, + background: token.colorPrimaryBg, + boxShadow: `inset 0 0 0 1px ${token.colorPrimary}`, + "&:hover": { + borderColor: token.colorPrimary, + }, + }, + targetCopy: { + display: "flex", + flexDirection: "column", + gap: 2, + }, + targetTitle: { + fontWeight: token.fontWeightStrong, + }, + targetDescription: { + color: token.colorTextSecondary, + fontSize: token.fontSizeSM, + }, + pickerRow: { + width: "100%", + }, + pickerSummary: { + flex: 1, + }, + selectedTags: { + display: "flex", + flexWrap: "wrap", + gap: token.marginXS, + }, + selectedTag: { + margin: 0, + }, +})); diff --git a/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/edit-value-targets-modal.tsx b/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/edit-value-targets-modal.tsx new file mode 100644 index 000000000..033857fed --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/edit-value-targets-modal.tsx @@ -0,0 +1,935 @@ +"use client"; + +import { + useCallback, + useEffect, + useState, +} from "react"; +import { + Alert, + App, + Button, + Checkbox, + Flex, + Input, + InputNumber, + Radio, + Select, + Tag, + Typography, +} from "antd"; +import { LuListPlus } from "react-icons/lu"; +import { + DiscountAllocationMethod, + DiscountKind, + DiscountRequirementType, + DiscountTargetType, + DiscountValueType, +} from "@/graphql/types"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { + useCategoryPicker, + useProductPicker, + useVariantPicker, +} from "@/shared/components/entity-picker-modal"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useUpdateDiscount } from "../../hooks"; +import { + buildDiscountValueTargetsUpdateInput, + createDiscountValueTargetsFormValues, + validateDiscountValueTargetsForm, + type DiscountTargetEditorItem, + type DiscountValueTargetsFormValues, +} from "../../mappers"; +import type { IDiscountValueTargetsEditModalPayload } from "../../modals"; +import { useEditValueTargetsModalStyles } from "./edit-value-targets-modal.styles"; + +const TARGET_OPTIONS = [ + { + value: DiscountTargetType.AllProducts, + title: "All products", + description: "Use the complete catalog.", + }, + { + value: DiscountTargetType.Products, + title: "Specific products", + description: "Select individual products.", + }, + { + value: DiscountTargetType.Variants, + title: "Specific variants", + description: "Select exact variants.", + }, + { + value: DiscountTargetType.Categories, + title: "Categories", + description: "Select catalog categories.", + }, +] as const; + +const TARGET_COPY = { + [DiscountTargetType.Products]: { + singular: "product", + plural: "products", + }, + [DiscountTargetType.Variants]: { + singular: "variant", + plural: "variants", + }, + [DiscountTargetType.Categories]: { + singular: "category", + plural: "categories", + }, +} as const; + +function getTargetCopy(targetType: DiscountTargetType) { + if (targetType === DiscountTargetType.Products) { + return TARGET_COPY[DiscountTargetType.Products]; + } + if (targetType === DiscountTargetType.Variants) { + return TARGET_COPY[DiscountTargetType.Variants]; + } + if (targetType === DiscountTargetType.Categories) { + return TARGET_COPY[DiscountTargetType.Categories]; + } + return null; +} + +const serializeValues = (values: DiscountValueTargetsFormValues) => + JSON.stringify({ + ...values, + targets: values.targets.map(({ id }) => id), + qualifierTargets: values.qualifierTargets.map(({ id }) => id), + }); + +function mergePickerSelection( + current: DiscountTargetEditorItem[], + entities: IPickableEntity[], + ids: string[], +): DiscountTargetEditorItem[] { + const titleById = new Map( + current.map((target) => [target.id, target.title]), + ); + entities.forEach((entity) => titleById.set(entity.id, entity.title)); + + return ids.map((id) => ({ + id, + title: titleById.get(id) ?? id, + })); +} + +function getCurrencySymbol(currency: string): string { + return ( + new Intl.NumberFormat("en-US", { + style: "currency", + currency, + currencyDisplay: "narrowSymbol", + }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value ?? currency + ); +} + +interface TargetSelectionEditorProps { + testIdPrefix: string; + title: string; + targetType: DiscountTargetType; + targets: DiscountTargetEditorItem[]; + onChange: ( + targetType: DiscountTargetType, + targets: DiscountTargetEditorItem[], + ) => void; +} + +function TargetSelectionEditor({ + testIdPrefix, + title, + targetType, + targets, + onChange, +}: TargetSelectionEditorProps) { + const { styles, cx } = useEditValueTargetsModalStyles(); + const selectedIds = targets.map((target) => target.id); + const targetCopy = getTargetCopy(targetType); + const handlePickerConfirm = useCallback( + (entities: IPickableEntity[], ids: string[]) => { + onChange( + targetType, + mergePickerSelection(targets, entities, ids), + ); + }, + [onChange, targetType, targets], + ); + const productPicker = useProductPicker({ + selectionMode: "multi", + initialSelection: selectedIds, + onConfirm: handlePickerConfirm, + }); + const variantPicker = useVariantPicker({ + selectionMode: "multi", + initialSelection: selectedIds, + onConfirm: handlePickerConfirm, + }); + const categoryPicker = useCategoryPicker({ + selectionMode: "multi", + initialSelection: selectedIds, + onConfirm: handlePickerConfirm, + }); + const openPicker = useCallback(() => { + if (targetType === DiscountTargetType.Products) { + productPicker.openPicker(); + } else if (targetType === DiscountTargetType.Variants) { + variantPicker.openPicker(); + } else if (targetType === DiscountTargetType.Categories) { + categoryPicker.openPicker(); + } + }, [categoryPicker, productPicker, targetType, variantPicker]); + + return ( + <> + + + + onChange(event.target.value as DiscountTargetType, []) + } + > + {TARGET_OPTIONS.map((option) => ( + + + + {option.title} + + + {option.description} + + + + ))} + + + + {targetCopy ? ( + + + {targets.length} selected + + } + /> +
    + + {targetCopy.plural[0].toUpperCase() + + targetCopy.plural.slice(1)}{" "} + * + + + + + +
    + {targets.length > 0 ? ( +
    + {targets.map((target) => ( + + onChange( + targetType, + targets.filter((item) => item.id !== target.id), + ) + } + > + {target.title} + + ))} +
    + ) : null} +
    + ) : null} + + ); +} + +export function EditValueTargetsModal() { + const { styles, cx } = useEditValueTargetsModalStyles(); + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); + const { discount, onSaved } = + payload as IDiscountValueTargetsEditModalPayload; + const mutation = useUpdateDiscount(); + const [values, setValues] = useState(() => + createDiscountValueTargetsFormValues(discount), + ); + const [initialSnapshot] = useState(() => + serializeValues(createDiscountValueTargetsFormValues(discount)), + ); + const [formError, setFormError] = useState(null); + const dirty = serializeValues(values) !== initialSnapshot; + const currencySymbol = getCurrencySymbol(discount.currency); + const isAmountOff = + discount.kind === DiscountKind.AmountOffProducts || + discount.kind === DiscountKind.AmountOffOrder; + const isBuyXGetY = discount.kind === DiscountKind.BuyXGetY; + const isFreeShipping = discount.kind === DiscountKind.FreeShipping; + + useEffect(() => { + setDirty(dirty); + }, [dirty, setDirty]); + + const updateValues = useCallback( + (changes: Partial) => { + setValues((current) => ({ ...current, ...changes })); + setFormError(null); + }, + [], + ); + + const save = useCallback(async () => { + const validationErrors = validateDiscountValueTargetsForm( + discount, + values, + ); + if (validationErrors.length > 0) { + setFormError(validationErrors.join(" ")); + return; + } + + const result = await mutation.updateDiscount({ + discountId: discount.id, + expectedRevision: discount.revision, + operations: buildDiscountValueTargetsUpdateInput(discount, values), + }); + + if (!result.discount || result.errors.length > 0) { + setFormError( + result.errors.map((error) => error.message).join(" ") || + "Unable to update discount.", + ); + return; + } + + setDirty(false); + message.success("Discount value and targets updated"); + forcePop(); + + if (onSaved) { + void Promise.resolve(onSaved()).catch(() => { + message.error("Discount saved, but the details could not be refreshed"); + }); + } + }, [ + discount, + forcePop, + message, + mutation, + onSaved, + setDirty, + values, + ]); + + const errorMessage = formError ?? mutation.error?.message ?? null; + + return ( + + } + bodyClassName={styles.body} + > +
    + {errorMessage ? ( + + ) : null} + + {isAmountOff ? ( + + +
    +
    + + Value type * + + + updateValues({ amount: event.target.value }) + } + /> + )} + + {values.valueType === DiscountValueType.Percentage + ? `Sent as percentageBps: ${Math.round( + (values.percentage ?? 0) * 100, + )}. Valid range is 1 to 10000.` + : "Sent as amountMinor; must be a positive amount."} + +
    +
    + + + updateValues({ + allocationMethod: event.target.checked + ? DiscountAllocationMethod.Each + : DiscountAllocationMethod.Across, + }) + } + > + + + Allocate to each eligible line + + + EACH when enabled; ACROSS when disabled. + + + + +
    + + Maximum discount + + + updateValues({ maximumDiscount: event.target.value }) + } + /> + + Optional maximumDiscountMinor; must be a positive amount. + +
    +
    + ) : null} + + {isFreeShipping ? ( + + +
    + + Maximum shipping price + + + updateValues({ + maximumShippingPrice: event.target.value, + }) + } + /> + + Leave empty to apply to all eligible shipping rates. + +
    +
    + ) : null} + + {isBuyXGetY ? ( + <> + + +
    +
    + + Requirement * + + + updateValues({ + requiredSubtotal: event.target.value, + }) + } + /> + )} +
    +
    +
    + + + +
    +
    + + Benefit quantity * + + + updateValues({ benefitQuantity }) + } + style={{ width: "100%" }} + /> +
    +
    + + Benefit value * + + + updateValues({ + benefitAmount: event.target.value, + }) + } + /> + )} +
    +
    + + Uses per order + + + updateValues({ usesPerOrderLimit }) + } + style={{ width: "100%" }} + /> +
    +
    + ) : ( +
    + + Uses per order + + + updateValues({ usesPerOrderLimit }) + } + style={{ width: "100%" }} + /> +
    + )} +
    + + ) : null} + + {discount.kind === DiscountKind.AmountOffProducts ? ( + + updateValues({ targetType, targets }) + } + /> + ) : null} + + {isBuyXGetY ? ( + <> + + updateValues({ + qualifierTargetType, + qualifierTargets, + }) + } + /> + + updateValues({ targetType, targets }) + } + /> + + ) : null} + + {discount.kind === DiscountKind.AmountOffOrder || + isFreeShipping ? ( + + +
    + + + {isFreeShipping + ? "Eligible shipping rates" + : "Entire order"} + + + {isFreeShipping + ? "Free shipping applies to eligible delivery methods." + : "Order discounts cannot contain catalog target selections."} + + +
    +
    + ) : null} + + {!isBuyXGetY ? ( + + +
    + + Requirement + + + updateValues({ minimumSubtotal: event.target.value }) + } + /> + + Sent as subtotalMinor; quantity is omitted. + +
    + ) : null} + + {values.requirementType === DiscountRequirementType.Quantity ? ( +
    + + Minimum quantity * + + + updateValues({ minimumQuantity }) + } + style={{ width: "100%" }} + /> + + Sent as quantity; subtotalMinor is omitted. + +
    + ) : null} +
    + ) : null} +
    +
    + ); +} diff --git a/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/index.ts b/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/index.ts new file mode 100644 index 000000000..1b5940bb9 --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/edit-value-targets-modal/index.ts @@ -0,0 +1 @@ +export { EditValueTargetsModal } from "./edit-value-targets-modal"; diff --git a/admin/src/domains/inventory/discounts/modals/select-discount-type-modal/discount-type-selector.tsx b/admin/src/domains/inventory/discounts/modals/select-discount-type-modal/discount-type-selector.tsx new file mode 100644 index 000000000..8dcbffc0d --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/select-discount-type-modal/discount-type-selector.tsx @@ -0,0 +1,175 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Typography } from "antd"; +import { createStyles } from "antd-style"; +import { + LuGift as GiftOutlined, + LuShoppingBag as OrderOutlined, + LuTags as TagsOutlined, + LuTruck as TruckOutlined, +} from "react-icons/lu"; +import { DiscountKind } from "@/graphql/types"; + +const useStyles = createStyles(({ token }) => ({ + options: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: 12, + "@media (max-width: 700px)": { + gridTemplateColumns: "minmax(0, 1fr)", + }, + }, + option: { + width: "100%", + minHeight: 72, + display: "flex", + alignItems: "center", + gap: 14, + padding: 16, + border: `1px solid ${token.colorBorder}`, + borderRadius: 12, + background: token.colorBgContainer, + color: token.colorText, + cursor: "pointer", + textAlign: "left", + font: "inherit", + boxSizing: "border-box", + transition: `border-color ${token.motionDurationMid}, background ${token.motionDurationMid}, box-shadow ${token.motionDurationMid}`, + "&:hover": { + background: token.colorFillQuaternary, + borderColor: token.colorPrimaryBorderHover, + }, + "&:focus-visible": { + outline: "none", + boxShadow: `0 0 0 ${token.controlOutlineWidth}px ${token.controlOutline}`, + }, + }, + optionSelected: { + borderColor: token.colorPrimary, + boxShadow: `inset 0 0 0 1px ${token.colorPrimary}`, + "&:hover, &:focus-visible": { + borderColor: token.colorPrimary, + }, + }, + icon: { + width: 40, + height: 40, + flex: "0 0 40px", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 10, + background: token.colorFillQuaternary, + color: token.colorText, + fontSize: 20, + }, + iconSelected: { + background: token.colorPrimaryBg, + color: token.colorPrimary, + }, + content: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: 4, + }, + title: { + display: "block", + fontSize: 15, + fontWeight: 600, + lineHeight: 1.35, + }, + description: { + display: "block", + fontSize: 12, + lineHeight: 1.35, + }, +})); + +interface DiscountTypeOption { + description: string; + icon: ReactNode; + kind: DiscountKind; + title: string; +} + +const OPTIONS: DiscountTypeOption[] = [ + { + kind: DiscountKind.AmountOffProducts, + title: "Amount off products", + description: "Reduce the price of selected products or collections.", + icon: , + }, + { + kind: DiscountKind.BuyXGetY, + title: "Buy X get Y", + description: "Reward customers when they buy qualifying items.", + icon: , + }, + { + kind: DiscountKind.AmountOffOrder, + title: "Amount off order", + description: "Apply a fixed or percentage discount to the order.", + icon: , + }, + { + kind: DiscountKind.FreeShipping, + title: "Free shipping", + description: "Remove shipping costs when conditions are met.", + icon: , + }, +]; + +interface DiscountTypeSelectorProps { + onSelect: (kind: DiscountKind) => void; + selectedKind?: DiscountKind; +} + +export function DiscountTypeSelector({ + onSelect, + selectedKind, +}: DiscountTypeSelectorProps) { + const { styles, cx } = useStyles(); + + return ( +
    + {OPTIONS.map((option) => ( + + ))} +
    + ); +} diff --git a/admin/src/domains/inventory/discounts/modals/select-discount-type-modal/index.ts b/admin/src/domains/inventory/discounts/modals/select-discount-type-modal/index.ts new file mode 100644 index 000000000..fbe5fe97b --- /dev/null +++ b/admin/src/domains/inventory/discounts/modals/select-discount-type-modal/index.ts @@ -0,0 +1 @@ +export { DiscountTypeSelector } from "./discount-type-selector"; diff --git a/admin/src/domains/inventory/discounts/page/filter-schema.ts b/admin/src/domains/inventory/discounts/page/filter-schema.ts new file mode 100644 index 000000000..3ddb699f4 --- /dev/null +++ b/admin/src/domains/inventory/discounts/page/filter-schema.ts @@ -0,0 +1,84 @@ +import { + FilterType, + dateOperators, + enumOperators, + numberOperators, + stringOperators, +} from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import { + DiscountEffectiveStatus, + DiscountKind, + DiscountMethod, +} from "@/graphql/types"; + +export const filterSchema: IFilterSchema[] = [ + { + key: "effectiveStatus", + label: "Status", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "effectiveStatus", + options: Object.values(DiscountEffectiveStatus).map((value) => ({ + label: value, + value, + })), + }, + { + key: "method", + label: "Method", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "method", + options: Object.values(DiscountMethod).map((value) => ({ + label: value, + value, + })), + }, + { + key: "kind", + label: "Discount type", + type: FilterType.Enum, + operators: enumOperators, + payloadKey: "kind", + options: Object.values(DiscountKind).map((value) => ({ + label: value.replaceAll("_", " "), + value, + })), + }, + { + key: "usageCount", + label: "Usage count", + type: FilterType.Number, + operators: numberOperators, + payloadKey: "usageCount", + }, + { + key: "startsAt", + label: "Start date", + type: FilterType.DateRange, + operators: dateOperators, + payloadKey: "startsAt", + }, + { + key: "endsAt", + label: "End date", + type: FilterType.DateRange, + operators: dateOperators, + payloadKey: "endsAt", + }, + { + key: "channelCode", + label: "Channel", + type: FilterType.String, + operators: stringOperators, + payloadKey: "channelCode", + }, + { + key: "tag", + label: "Tag", + type: FilterType.String, + operators: stringOperators, + payloadKey: "tag", + }, +]; diff --git a/admin/src/domains/inventory/discounts/page/page-config.ts b/admin/src/domains/inventory/discounts/page/page-config.ts new file mode 100644 index 000000000..c5933ea7a --- /dev/null +++ b/admin/src/domains/inventory/discounts/page/page-config.ts @@ -0,0 +1,74 @@ +import type { + FilterTransformer, + SortFieldMapping, + UsePageConfigReturn, +} from "@/hooks"; +import { + createGraphqlDateTimeRangeFilterTransformer, + createGraphqlIntFilterTransformer, + createGraphqlStringFilterTransformer, +} from "@/layouts/filters"; +import type { + ApiDiscountOrderByInput, + ApiDiscountWhereInput, +} from "@/graphql/types"; +import { DiscountOrderField } from "@/graphql/types"; +import type { DiscountsQueryVariables } from "../graphql"; + +export const discountSortFieldMapping: SortFieldMapping = { + title: DiscountOrderField.Title, + primaryCode: DiscountOrderField.PrimaryCode, + method: DiscountOrderField.Method, + kind: DiscountOrderField.Kind, + effectiveStatus: DiscountOrderField.EffectiveStatus, + usageCount: DiscountOrderField.UsageCount, + startsAt: DiscountOrderField.StartsAt, + endsAt: DiscountOrderField.EndsAt, + updatedAt: DiscountOrderField.UpdatedAt, +}; + +export const discountFilterTransformers: Record< + string, + FilterTransformer +> = { + usageCount: + createGraphqlIntFilterTransformer("usageCount"), + startsAt: + createGraphqlDateTimeRangeFilterTransformer( + "startsAt", + ), + endsAt: + createGraphqlDateTimeRangeFilterTransformer( + "endsAt", + ), + channelCode: + createGraphqlStringFilterTransformer("channelCode"), + tag: createGraphqlStringFilterTransformer("tag"), +}; + +export const buildDiscountSearchCondition = ( + search: string, +): Partial => ({ + _or: [ + { title: { _containsi: search } }, + { code: { _containsi: search } }, + ], +}); + +export function buildDiscountsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): DiscountsQueryVariables { + return { + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: (pageConfig.orderBy ?? null) as + | ApiDiscountOrderByInput[] + | null, + }; +} diff --git a/admin/src/domains/inventory/discounts/page/page.tsx b/admin/src/domains/inventory/discounts/page/page.tsx new file mode 100644 index 000000000..56754a1e7 --- /dev/null +++ b/admin/src/domains/inventory/discounts/page/page.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Alert, Button, Flex, Tag, Typography } from "antd"; +import { + AllCommunityModule, + GridStateModule, + ModuleRegistry, + type ColDef, +} from "ag-grid-community"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { DataLayout } from "@/layouts/data"; +import { FilterWidget } from "@/layouts/filters"; +import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, usePageConfig } from "@/hooks"; +import type { ApiDiscount, ApiDiscountWhereInput } from "@/graphql/types"; +import { + DiscountEffectiveStatus, + DiscountOrderField, +} from "@/graphql/types"; +import { useDiscounts } from "../hooks"; +import { useCreateDiscountModal, useDiscountModal } from "../modals"; +import { filterSchema } from "./filter-schema"; +import { + buildDiscountSearchCondition, + buildDiscountsQueryVariables, + discountFilterTransformers, + discountSortFieldMapping, +} from "./page-config"; + +ModuleRegistry.registerModules([AllCommunityModule, GridStateModule]); + +const statusColors: Record = { + [DiscountEffectiveStatus.Draft]: "default", + [DiscountEffectiveStatus.Scheduled]: "blue", + [DiscountEffectiveStatus.Active]: "green", + [DiscountEffectiveStatus.Paused]: "gold", + [DiscountEffectiveStatus.Expired]: "red", + [DiscountEffectiveStatus.Archived]: "default", +}; + +const dateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +function formatEnum(value: string): string { + return value + .toLowerCase() + .replaceAll("_", " ") + .replace(/^./, (letter) => letter.toUpperCase()); +} + +function DiscountCell({ data }: CustomCellRendererProps) { + if (!data) return null; + + return ( + + + {data.title ?? data.primaryCode ?? "Untitled discount"} + + {data.primaryCode && data.title && ( + + {data.primaryCode} + {data.codesCount > 1 ? ` +${data.codesCount - 1}` : ""} + + )} + + ); +} + +function StatusCell({ + value, +}: CustomCellRendererProps) { + if (!value) return null; + return {formatEnum(value)}; +} + +function UsageCell({ data }: CustomCellRendererProps) { + if (!data) return null; + const limit = data.usageLimit == null ? "Unlimited" : String(data.usageLimit); + return ( + + {String(data.usageCount)} / {limit} + + ); +} + +function ScheduleCell({ data }: CustomCellRendererProps) { + if (!data) return null; + const start = dateFormatter.format(new Date(data.startsAt)); + const end = data.endsAt ? dateFormatter.format(new Date(data.endsAt)) : "No end"; + return {start} — {end}; +} + +function DateCell({ value }: CustomCellRendererProps) { + return ( + + {value ? dateFormatter.format(new Date(value)) : ""} + + ); +} + +export default function DiscountsPage() { + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const pageConfig = usePageConfig< + ApiDiscount, + ApiDiscountWhereInput, + DiscountOrderField + >({ + gridRef, + storageKey: "discounts-grid-state", + filterSchema, + sortFieldMapping: discountSortFieldMapping, + defaultSort: [{ colId: "updatedAt", sort: "desc" }], + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], + buildSearchCondition: buildDiscountSearchCondition, + filterTransformers: discountFilterTransformers, + }); + const variables = useMemo( + () => buildDiscountsQueryVariables(pageConfig), + [ + pageConfig.first, + pageConfig.after, + pageConfig.last, + pageConfig.before, + pageConfig.where, + pageConfig.orderBy, + ], + ); + const { discounts, totalCount, pageInfo, loading, error, refetch } = + useDiscounts(variables); + const { push: openCreateDiscountModal } = useCreateDiscountModal(); + const { push: openDiscountModal } = useDiscountModal(); + + const handleOpenCreateDiscountModal = useCallback(() => { + openCreateDiscountModal({ + onCreated: () => refetch(), + }); + }, [openCreateDiscountModal, refetch]); + + const handleNextPage = useCallback(() => { + if (pageInfo?.endCursor) { + pageConfig.goToNextPage(pageInfo.endCursor); + } + }, [pageConfig, pageInfo?.endCursor]); + + const handlePrevPage = useCallback(() => { + if (pageInfo?.startCursor) { + pageConfig.goToPrevPage(pageInfo.startCursor); + } + }, [pageConfig, pageInfo?.startCursor]); + + const columnDefs = useMemo[]>( + () => [ + { + headerName: "Discount", + field: "title", + cellRenderer: DiscountCell, + minWidth: 280, + flex: 2, + }, + { + headerName: "Method", + field: "method", + valueFormatter: ({ value }) => value ? formatEnum(value) : "", + width: 125, + }, + { + headerName: "Type", + field: "kind", + valueFormatter: ({ value }) => value ? formatEnum(value) : "", + minWidth: 190, + flex: 1, + }, + { + headerName: "Status", + field: "effectiveStatus", + cellRenderer: StatusCell, + width: 130, + }, + { + headerName: "Usage", + colId: "usageCount", + cellRenderer: UsageCell, + width: 150, + }, + { + headerName: "Schedule", + colId: "startsAt", + cellRenderer: ScheduleCell, + minWidth: 230, + }, + { + headerName: "Updated", + field: "updatedAt", + cellRenderer: DateCell, + minWidth: 140, + }, + ], + [], + ); + + const defaultColDef = useMemo>( + () => ({ + resizable: true, + sortable: true, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }), + [], + ); + + return ( + } + onClick={handleOpenCreateDiscountModal} + > + Create + + } + > + + } + /> + +
    + {error && ( + + )} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={discounts} + loading={loading} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={64} + rowStyle={{ cursor: "pointer" }} + onRowClicked={(event) => { + if (event.data) { + openDiscountModal({ entityId: event.data.id }); + } + }} + suppressCellFocus + suppressMovableColumns + onSortChanged={pageConfig.onSortChanged} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + /> +
    + + +
    +
    + ); +} diff --git a/admin/src/domains/inventory/domain.tsx b/admin/src/domains/inventory/domain.tsx index 1c2dd5205..50d6dd019 100644 --- a/admin/src/domains/inventory/domain.tsx +++ b/admin/src/domains/inventory/domain.tsx @@ -1,6 +1,7 @@ import { AppLayout } from "@/layouts/app/components/layout/layout"; import { registerDomain } from "@/registry"; -import { ShopOutlined } from "@ant-design/icons"; +import { LuStore as ShopOutlined } from "react-icons/lu"; +import { InventorySidebarConfigLoader } from "./components/inventory-sidebar-config-loader"; registerDomain({ key: "store", @@ -10,4 +11,5 @@ registerDomain({ icon: , order: 1, }, + sidebarRuntime: InventorySidebarConfigLoader, }); diff --git a/admin/src/domains/inventory/featured-products/page/page.tsx b/admin/src/domains/inventory/featured-products/page/page.tsx new file mode 100644 index 000000000..413ab366e --- /dev/null +++ b/admin/src/domains/inventory/featured-products/page/page.tsx @@ -0,0 +1,7 @@ +import { EmptySectionPage } from "@/shared/components/empty-section-page"; + +export default function FeaturedProductsPage() { + return ( + + ); +} diff --git a/admin/src/domains/inventory/graphql/fragments.ts b/admin/src/domains/inventory/graphql/fragments.ts index 28436900e..29149d7f5 100644 --- a/admin/src/domains/inventory/graphql/fragments.ts +++ b/admin/src/domains/inventory/graphql/fragments.ts @@ -1,161 +1,24 @@ -import { gql } from "@apollo/client"; - -/** - * GraphQL fragments for inventory domain entities. - */ - -// Generic user error fragment (reused from workspace) -export const USER_ERROR_FRAGMENT = gql` - fragment UserErrorFields on GenericUserError { - code - field - message - } -`; - -// Product description fragment -export const DESCRIPTION_FRAGMENT = gql` - fragment DescriptionFields on Description { - text - html - json - } -`; - -// Product option value fragment -export const PRODUCT_OPTION_VALUE_FRAGMENT = gql` - fragment ProductOptionValueFields on ProductOptionValue { - id - value - slug - } -`; - -// Product option fragment -export const PRODUCT_OPTION_FRAGMENT = gql` - fragment ProductOptionFields on ProductOption { - id - name - slug - displayType - values { - ...ProductOptionValueFields - } - } - ${PRODUCT_OPTION_VALUE_FRAGMENT} -`; - -// Selected option fragment (for variants) -export const SELECTED_OPTION_FRAGMENT = gql` - fragment SelectedOptionFields on SelectedOption { - name - value - } -`; - -// Variant media item fragment -export const VARIANT_MEDIA_ITEM_FRAGMENT = gql` - fragment VariantMediaItemFields on VariantMediaItem { - id - position - file { - id - url - name - size - mimeType - altText - } - } -`; - -// Variant fragment - minimal for lists -export const VARIANT_BASIC_FRAGMENT = gql` - fragment VariantBasicFields on Variant { - id - handle - sku - isDefault - selectedOptions { - ...SelectedOptionFields - } - } - ${SELECTED_OPTION_FRAGMENT} -`; - -// Variant fragment - full -export const VARIANT_FRAGMENT = gql` - fragment VariantFields on Variant { - id - handle - sku - isDefault - selectedOptions { - ...SelectedOptionFields - } - media { - ...VariantMediaItemFields - } - } - ${SELECTED_OPTION_FRAGMENT} - ${VARIANT_MEDIA_ITEM_FRAGMENT} -`; - -// Product fragment - minimal for lists -export const PRODUCT_BASIC_FRAGMENT = gql` - fragment ProductBasicFields on Product { - id - title - handle - isPublished - publishedAt - createdAt - updatedAt - } -`; - -// Product fragment - full with variants and options -export const PRODUCT_FRAGMENT = gql` - fragment ProductFields on Product { - id - title - handle - isPublished - publishedAt - excerpt - seoTitle - seoDescription - createdAt - updatedAt - description { - ...DescriptionFields - } - options { - ...ProductOptionFields - } - variants(first: 100) { - edges { - node { - ...VariantFields - } - } - totalCount - } - variantsCount - } - ${DESCRIPTION_FRAGMENT} - ${PRODUCT_OPTION_FRAGMENT} - ${VARIANT_FRAGMENT} -`; - -// File fragment for media uploads -export const FILE_FRAGMENT = gql` - fragment FileFields on File { - id - url - name - size - mimeType - altText - } -`; +export { + FILE_FRAGMENT, + RICH_TEXT_FRAGMENT, + USER_ERROR_FRAGMENT, +} from "./shared-fragments"; + +export { + INVENTORY_ITEM_FRAGMENT, + PRODUCT_BASIC_FRAGMENT, + PRODUCT_CATEGORY_FRAGMENT, + PRODUCT_DETAILS_FRAGMENT, + PRODUCT_FEATURE_FRAGMENT, + PRODUCT_FRAGMENT, + PRODUCT_LIST_FRAGMENT, + PRODUCT_MEDIA_ITEM_FRAGMENT, + PRODUCT_MUTATION_RESULT_FRAGMENT, + PRODUCT_OPTION_FRAGMENT, + PRODUCT_OPTION_VALUE_FRAGMENT, + PRODUCT_TAG_FRAGMENT, + SELECTED_OPTION_FRAGMENT, + VARIANT_BASIC_FRAGMENT, + VARIANT_FRAGMENT, + VARIANT_MEDIA_ITEM_FRAGMENT, +} from "../products/graphql/fragments"; diff --git a/admin/src/domains/inventory/graphql/index.ts b/admin/src/domains/inventory/graphql/index.ts index 3623d0572..afe88727e 100644 --- a/admin/src/domains/inventory/graphql/index.ts +++ b/admin/src/domains/inventory/graphql/index.ts @@ -1,40 +1,2 @@ -/** - * Inventory domain GraphQL operations. - * Exports all fragments and mutations for the Inventory and Media services. - */ - -// Re-export all fragments -export { - // Error - USER_ERROR_FRAGMENT, - // Description - DESCRIPTION_FRAGMENT, - // Option - PRODUCT_OPTION_VALUE_FRAGMENT, - PRODUCT_OPTION_FRAGMENT, - // Variant - SELECTED_OPTION_FRAGMENT, - VARIANT_MEDIA_ITEM_FRAGMENT, - VARIANT_BASIC_FRAGMENT, - VARIANT_FRAGMENT, - // Product - PRODUCT_BASIC_FRAGMENT, - PRODUCT_FRAGMENT, - // File - FILE_FRAGMENT, -} from "./fragments"; - -// Re-export all mutations -export { - // Product - PRODUCT_CREATE_MUTATION, - PRODUCT_UPDATE_MUTATION, - PRODUCT_DELETE_MUTATION, - PRODUCT_PUBLISH_MUTATION, - // Product Option - PRODUCT_OPTION_CREATE_MUTATION, - // Variant - VARIANT_SET_MEDIA_MUTATION, - // Media - FILE_UPLOAD_MUTATION, -} from "./mutations"; +export * from "./fragments"; +export * from "./mutations"; diff --git a/admin/src/domains/inventory/graphql/mutations.ts b/admin/src/domains/inventory/graphql/mutations.ts index 4bb9dbf74..57c9300b3 100644 --- a/admin/src/domains/inventory/graphql/mutations.ts +++ b/admin/src/domains/inventory/graphql/mutations.ts @@ -1,191 +1,5 @@ -import { gql } from "@apollo/client"; -import { - USER_ERROR_FRAGMENT, - PRODUCT_FRAGMENT, - PRODUCT_BASIC_FRAGMENT, - PRODUCT_OPTION_FRAGMENT, - VARIANT_FRAGMENT, - FILE_FRAGMENT, -} from "./fragments"; - -/** - * GraphQL mutations for inventory domain. - */ - -// ============================================ -// Product Mutations -// ============================================ - -/** - * Create a new product with all its data in one request. - */ -export const PRODUCT_CREATE_MUTATION = gql` - mutation ProductCreate($input: ProductCreateInput!) { - inventoryMutation { - productCreate(input: $input) { - product { - id - title - handle - variants(first: 100) { - edges { - node { - id - isDefault - } - } - } - } - userErrors { - ...UserErrorFields - } - } - } - } - ${USER_ERROR_FRAGMENT} -`; - -/** - * Update an existing product. - */ -export const PRODUCT_UPDATE_MUTATION = gql` - mutation ProductUpdate($input: ProductUpdateInput!) { - inventoryMutation { - productUpdate(input: $input) { - product { - ...ProductBasicFields - } - userErrors { - ...UserErrorFields - } - } - } - } - ${PRODUCT_BASIC_FRAGMENT} - ${USER_ERROR_FRAGMENT} -`; - -/** - * Delete a product. - */ -export const PRODUCT_DELETE_MUTATION = gql` - mutation ProductDelete($input: ProductDeleteInput!) { - inventoryMutation { - productDelete(input: $input) { - deletedProductId - userErrors { - ...UserErrorFields - } - } - } - } - ${USER_ERROR_FRAGMENT} -`; - -/** - * Publish a product. - */ -export const PRODUCT_PUBLISH_MUTATION = gql` - mutation ProductPublish($input: ProductPublishInput!) { - inventoryMutation { - productPublish(input: $input) { - product { - ...ProductBasicFields - } - userErrors { - ...UserErrorFields - } - } - } - } - ${PRODUCT_BASIC_FRAGMENT} - ${USER_ERROR_FRAGMENT} -`; - -// ============================================ -// Product Option Mutations -// ============================================ - -/** - * Create a new product option. - * This automatically creates variant combinations. - */ -export const PRODUCT_OPTION_CREATE_MUTATION = gql` - mutation ProductOptionCreate($input: ProductOptionCreateInput!) { - inventoryMutation { - productOptionCreate(input: $input) { - product { - id - options { - ...ProductOptionFields - } - variants(first: 100) { - edges { - node { - ...VariantFields - } - } - totalCount - } - } - option { - ...ProductOptionFields - } - userErrors { - ...UserErrorFields - } - } - } - } - ${PRODUCT_OPTION_FRAGMENT} - ${VARIANT_FRAGMENT} - ${USER_ERROR_FRAGMENT} -`; - -// ============================================ -// Variant Mutations -// ============================================ - -/** - * Set media for a variant. - */ -export const VARIANT_SET_MEDIA_MUTATION = gql` - mutation VariantSetMedia($input: VariantSetMediaInput!) { - inventoryMutation { - variantSetMedia(input: $input) { - variant { - ...VariantFields - } - userErrors { - ...UserErrorFields - } - } - } - } - ${VARIANT_FRAGMENT} - ${USER_ERROR_FRAGMENT} -`; - -// ============================================ -// Media Mutations -// ============================================ - -/** - * Upload a file to the media service. - */ -export const FILE_UPLOAD_MUTATION = gql` - mutation FileUpload($input: FileUploadMultipartInput!) { - mediaMutation { - fileUpload(input: $input) { - file { - ...FileFields - } - userErrors { - ...UserErrorFields - } - } - } - } - ${FILE_FRAGMENT} - ${USER_ERROR_FRAGMENT} -`; +export { + PRODUCT_CREATE_MUTATION, + PRODUCT_DELETE_MUTATION, + PRODUCT_UPDATE_MUTATION, +} from "../products/graphql/mutations"; diff --git a/admin/src/domains/inventory/graphql/shared-fragments.ts b/admin/src/domains/inventory/graphql/shared-fragments.ts new file mode 100644 index 000000000..5896f52b9 --- /dev/null +++ b/admin/src/domains/inventory/graphql/shared-fragments.ts @@ -0,0 +1,60 @@ +import { gql } from "@apollo/client"; + +export const USER_ERROR_FRAGMENT = gql` + fragment UserErrorFields on GenericUserError { + code + field + message + } +`; + +export const RICH_TEXT_FRAGMENT = gql` + fragment RichTextFields on RichText { + text + html + json + } +`; + +export const FILE_FRAGMENT = gql` + fragment FileFields on File { + id + url + originalName + ext + mimeType + sizeBytes + provider + isProcessed + altText + createdAt + updatedAt + deletedAt + deletionState + dimensions { + width + height + } + durationMs + externalData { + externalId + providerMeta + } + meta + s3Data { + bucketId + etag + objectKey + storageClass + } + sourceUrl + usage { + totalCount + fileActive + byEntity { + entityType + count + } + } + } +`; diff --git a/admin/src/domains/inventory/hooks/index.ts b/admin/src/domains/inventory/hooks/index.ts new file mode 100644 index 000000000..415253ba0 --- /dev/null +++ b/admin/src/domains/inventory/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-inventory-relay-list-page"; diff --git a/admin/src/domains/inventory/hooks/use-inventory-relay-list-page.ts b/admin/src/domains/inventory/hooks/use-inventory-relay-list-page.ts new file mode 100644 index 000000000..87ec26598 --- /dev/null +++ b/admin/src/domains/inventory/hooks/use-inventory-relay-list-page.ts @@ -0,0 +1,158 @@ +"use client"; + +import { useCallback, useMemo, type RefObject } from "react"; +import type { AgGridReact } from "ag-grid-react"; +import type { ApiPageInfo } from "@/graphql/types"; +import { usePageConfig } from "@/hooks"; +import type { + FilterTransformer, + SortFieldMapping, + UsePageConfigReturn, +} from "@/hooks"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; + +type ListPageConfigInput< + TWhereInput extends object, + TOrderField extends string, +> = Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" +>; + +export interface InventoryRelayListQueryResult { + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export interface UseInventoryRelayListPageOptions< + TData, + TWhereInput extends object, + TOrderField extends string, + TQueryVariables extends RelayCursorPaginationVariables, + TListResult extends InventoryRelayListQueryResult, +> { + gridRef: RefObject | null>; + storageKey: string; + filterSchema: IFilterSchema[]; + sortFieldMapping: SortFieldMapping; + defaultPageSize?: number; + pageSizeOptions?: number[]; + buildSearchCondition?: (search: string) => Partial; + filterTransformers?: Record>; + buildQueryVariables: ( + pageConfig: ListPageConfigInput, + ) => TQueryVariables; + useListQuery: (variables: TQueryVariables) => TListResult; + getItems: (result: TListResult) => TData[]; +} + +export interface UseInventoryRelayListPageReturn< + TData, + TWhereInput extends object, + TOrderField extends string, + TQueryVariables extends RelayCursorPaginationVariables, + TListResult extends InventoryRelayListQueryResult, +> { + pageConfig: UsePageConfigReturn; + listQueryVariables: TQueryVariables; + listResult: TListResult; + items: TData[]; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; + handleNextPage: () => void; + handlePrevPage: () => void; +} + +export function useInventoryRelayListPage< + TData, + TWhereInput extends object, + TOrderField extends string, + TQueryVariables extends RelayCursorPaginationVariables, + TListResult extends InventoryRelayListQueryResult, +>({ + gridRef, + storageKey, + filterSchema, + sortFieldMapping, + defaultPageSize = 20, + pageSizeOptions, + buildSearchCondition, + filterTransformers, + buildQueryVariables, + useListQuery, + getItems, +}: UseInventoryRelayListPageOptions< + TData, + TWhereInput, + TOrderField, + TQueryVariables, + TListResult +>): UseInventoryRelayListPageReturn< + TData, + TWhereInput, + TOrderField, + TQueryVariables, + TListResult +> { + const pageConfig = usePageConfig({ + gridRef, + storageKey, + filterSchema, + sortFieldMapping, + defaultPageSize, + pageSizeOptions, + buildSearchCondition, + filterTransformers, + }); + + const listQueryVariables = useMemo( + () => buildQueryVariables(pageConfig), + [ + buildQueryVariables, + pageConfig.first, + pageConfig.after, + pageConfig.last, + pageConfig.before, + pageConfig.where, + pageConfig.orderBy, + ], + ); + + const listResult = useListQuery(listQueryVariables); + const items = getItems(listResult); + const { pageInfo } = listResult; + const { goToNextPage, goToPrevPage } = pageConfig; + + const handleNextPage = useCallback(() => { + if (pageInfo?.endCursor) { + goToNextPage(pageInfo.endCursor); + } + }, [goToNextPage, pageInfo?.endCursor]); + + const handlePrevPage = useCallback(() => { + if (pageInfo?.startCursor) { + goToPrevPage(pageInfo.startCursor); + } + }, [goToPrevPage, pageInfo?.startCursor]); + + return { + pageConfig, + listQueryVariables, + listResult, + items, + totalCount: listResult.totalCount, + pageInfo, + loading: listResult.loading, + error: listResult.error, + refetch: listResult.refetch, + handleNextPage, + handlePrevPage, + }; +} diff --git a/admin/src/domains/inventory/inventory/components/calculated-available-cell.tsx b/admin/src/domains/inventory/inventory/components/calculated-available-cell.tsx index c01495f65..9453e7df1 100644 --- a/admin/src/domains/inventory/inventory/components/calculated-available-cell.tsx +++ b/admin/src/domains/inventory/inventory/components/calculated-available-cell.tsx @@ -1,16 +1,17 @@ "use client"; import type { CustomCellRendererProps } from "ag-grid-react"; -import type { IInventoryListItem } from "@/mocks/inventory/inventory-list"; import { useInventoryEditStore } from "../hooks/use-inventory-edit-store"; import { CalculatedAvailableCell as SharedCalculatedAvailableCell } from "@/shared/components/inventory-cells"; +import type { InventoryVariantRow } from "../mappers"; +import { getInventoryVariantCellTestId } from "./test-ids"; /** * AG Grid cell renderer for calculated available inventory * Uses shared CalculatedAvailableCell component */ export function CalculatedAvailableCell( - props: CustomCellRendererProps + props: CustomCellRendererProps ) { const { data } = props; const { getFieldEdit } = useInventoryEditStore(); @@ -26,6 +27,7 @@ export function CalculatedAvailableCell( onHand={data.onHand} unavailable={data.unavailable} reserved={data.reserved} + testId={getInventoryVariantCellTestId(data, "available")} onHandEdit={onHandEdit ? { originalValue: onHandEdit.originalValue as number, currentValue: onHandEdit.currentValue as number, diff --git a/admin/src/domains/inventory/inventory/components/cell-renderers.tsx b/admin/src/domains/inventory/inventory/components/cell-renderers.tsx index 13acfaef5..49572a8a3 100644 --- a/admin/src/domains/inventory/inventory/components/cell-renderers.tsx +++ b/admin/src/domains/inventory/inventory/components/cell-renderers.tsx @@ -1,9 +1,12 @@ -import { Image, Typography, Flex } from "antd"; +import { Typography, Flex } from "antd"; +import { LuImage as PictureOutlined } from "react-icons/lu"; import { createStyles } from "antd-style"; import type { CustomCellRendererProps } from "ag-grid-react"; -import type { IInventoryListItem } from "@/mocks/inventory/inventory-list"; import { EditableNumberCell } from "./editable-number-cell"; import { ReservedCell } from "@/shared/components/inventory-cells"; +import type { InventoryVariantRow } from "../mappers"; +import { getInventoryVariantCellTestId } from "./test-ids"; +import { TableCoverImage } from "@/shared/components/table-cover-image"; const useStyles = createStyles(({ token }) => ({ productImage: { @@ -19,29 +22,39 @@ const useStyles = createStyles(({ token }) => ({ })); export const ProductCellRenderer = ( - props: CustomCellRendererProps + props: CustomCellRendererProps ) => { const { styles } = useStyles(); const { data } = props; if (!data) return null; return ( - - {data.productName} + } className={styles.productImage} - preview={false} /> - - {data.productName} + + {data.productTitle} - {data.variantName && ( - - {data.variantName} + {(data.variantTitle || data.variantHandle) && ( + + {data.variantTitle ?? data.variantHandle} )} @@ -50,16 +63,21 @@ export const ProductCellRenderer = ( }; export const ReservedCellRenderer = ( - props: CustomCellRendererProps + props: CustomCellRendererProps ) => { - const { value } = props; - return ; + const { data, value } = props; + return ( + + ); }; export const OnHandCellRenderer = ( - props: CustomCellRendererProps + props: CustomCellRendererProps ) => ; export const UnavailableCellRenderer = ( - props: CustomCellRendererProps + props: CustomCellRendererProps ) => ; diff --git a/admin/src/domains/inventory/inventory/components/editable-number-cell.tsx b/admin/src/domains/inventory/inventory/components/editable-number-cell.tsx index 3f1a31558..94198dc01 100644 --- a/admin/src/domains/inventory/inventory/components/editable-number-cell.tsx +++ b/admin/src/domains/inventory/inventory/components/editable-number-cell.tsx @@ -1,15 +1,16 @@ "use client"; import type { CustomCellRendererProps } from "ag-grid-react"; -import type { IInventoryListItem } from "@/mocks/inventory/inventory-list"; import { useInventoryEditStore, type EditableField, } from "../hooks/use-inventory-edit-store"; import { EditableInventoryCell } from "@/shared/components/inventory-cells"; +import type { InventoryVariantRow } from "../mappers"; +import { getInventoryVariantCellTestId } from "./test-ids"; interface EditableNumberCellProps - extends CustomCellRendererProps { + extends CustomCellRendererProps { field: EditableField; } @@ -25,10 +26,12 @@ export function EditableNumberCell(props: EditableNumberCellProps) { const fieldEdit = getFieldEdit(data.id, field); const currentValue = value as number; + const testField = field === "onHand" ? "on-hand" : "unavailable"; return ( & { + inventoryItems: ApiInventoryItemConnection; + }; +} + +export interface InventoryItemsQueryVariables { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: ApiInventoryItemWhereInput | null; + orderBy?: ApiInventoryItemOrderByInput[] | null; + meta?: ApiInventoryItemInventoryItemsMetaInput | null; +} + +export type InventoryVariantsQueryData = InventoryItemsQueryData; +export type InventoryVariantsQueryVariables = InventoryItemsQueryVariables; + +export interface InventoryProductBulkUpdateMutationData { + catalogMutation: Pick & { + productBulkUpdate: { + job: { + id: string; + status: BulkUpdateJobStatus; + } | null; + userErrors: ApiBulkUpdateUserError[]; + }; + }; +} + +export interface InventoryProductBulkUpdateMutationVariables { + input: ApiProductBulkUpdateInput; +} + +export interface WarehouseStockCreateMutationData { + inventoryMutation: Pick & { + warehouseStockCreate: { + warehouseStocks: ApiWarehouseStock[]; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface WarehouseStockCreateMutationVariables { + input: ApiWarehouseStockCreateInput; +} + +export interface WarehouseStockDeleteMutationData { + inventoryMutation: Pick & { + warehouseStockDelete: { + deletedWarehouseStockIds: string[]; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface WarehouseStockDeleteMutationVariables { + input: ApiWarehouseStockDeleteInput; +} diff --git a/admin/src/domains/inventory/inventory/graphql/queries.ts b/admin/src/domains/inventory/inventory/graphql/queries.ts new file mode 100644 index 000000000..c85cf9930 --- /dev/null +++ b/admin/src/domains/inventory/inventory/graphql/queries.ts @@ -0,0 +1,43 @@ +import { gql } from "@apollo/client"; +import { INVENTORY_ITEM_ROW_FRAGMENT } from "./fragments"; + +export const INVENTORY_ITEMS_QUERY = gql` + query InventoryItems( + $first: Int + $after: String + $last: Int + $before: String + $where: InventoryItemWhereInput + $orderBy: [InventoryItemOrderByInput!] + $meta: InventoryItemInventoryItemsMetaInput + ) { + inventoryQuery { + inventoryItems( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + meta: $meta + ) { + edges { + cursor + node { + ...InventoryItemRowFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + ${INVENTORY_ITEM_ROW_FRAGMENT} +`; + +export const INVENTORY_VARIANTS_QUERY = INVENTORY_ITEMS_QUERY; diff --git a/admin/src/domains/inventory/inventory/hooks/index.ts b/admin/src/domains/inventory/inventory/hooks/index.ts index e7e3fc770..1e791a2a1 100644 --- a/admin/src/domains/inventory/inventory/hooks/index.ts +++ b/admin/src/domains/inventory/inventory/hooks/index.ts @@ -1,2 +1,7 @@ +export * from "./use-create-warehouse-stock"; +export * from "./use-delete-warehouse-stock"; export * from "./use-inventory"; export * from "./use-inventory-edit-store"; +export * from "./use-inventory-items"; +export * from "./use-inventory-variants"; +export * from "./use-save-inventory-variant-edits"; diff --git a/admin/src/domains/inventory/inventory/hooks/use-create-warehouse-stock.ts b/admin/src/domains/inventory/inventory/hooks/use-create-warehouse-stock.ts new file mode 100644 index 000000000..caa77a5a3 --- /dev/null +++ b/admin/src/domains/inventory/inventory/hooks/use-create-warehouse-stock.ts @@ -0,0 +1,74 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiWarehouseStock, + ApiWarehouseStockCreateInput, +} from "@/graphql/types"; +import { WAREHOUSE_STOCK_CREATE_MUTATION } from "../graphql"; +import type { + WarehouseStockCreateMutationData, + WarehouseStockCreateMutationVariables, +} from "../graphql"; + +interface CreateWarehouseStockResult { + warehouseStocks: ApiWarehouseStock[]; + userErrors: ApiGenericUserError[]; +} + +interface UseCreateWarehouseStockReturn { + createWarehouseStock: ( + input: ApiWarehouseStockCreateInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +function toUnexpectedError(err: unknown): ApiGenericUserError { + return { + code: "UNEXPECTED_ERROR", + message: err instanceof Error ? err.message : "An unexpected error occurred", + }; +} + +export function useCreateWarehouseStock(): UseCreateWarehouseStockReturn { + const [createWarehouseStockMutation, { loading, error, reset }] = useMutation< + WarehouseStockCreateMutationData, + WarehouseStockCreateMutationVariables + >(WAREHOUSE_STOCK_CREATE_MUTATION); + + const createWarehouseStock = useCallback( + async ( + input: ApiWarehouseStockCreateInput, + ): Promise => { + try { + const result = await createWarehouseStockMutation({ + variables: { input }, + }); + const payload = + result.data?.inventoryMutation.warehouseStockCreate ?? null; + + return { + warehouseStocks: payload?.warehouseStocks ?? [], + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + return { + warehouseStocks: [], + userErrors: [toUnexpectedError(err)], + }; + } + }, + [createWarehouseStockMutation], + ); + + return { + createWarehouseStock, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/inventory/hooks/use-delete-warehouse-stock.ts b/admin/src/domains/inventory/inventory/hooks/use-delete-warehouse-stock.ts new file mode 100644 index 000000000..1fae77cbb --- /dev/null +++ b/admin/src/domains/inventory/inventory/hooks/use-delete-warehouse-stock.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiWarehouseStockDeleteInput, +} from "@/graphql/types"; +import { WAREHOUSE_STOCK_DELETE_MUTATION } from "../graphql"; +import type { + WarehouseStockDeleteMutationData, + WarehouseStockDeleteMutationVariables, +} from "../graphql"; + +interface DeleteWarehouseStockResult { + deletedWarehouseStockIds: string[]; + userErrors: ApiGenericUserError[]; +} + +interface UseDeleteWarehouseStockReturn { + deleteWarehouseStock: ( + input: ApiWarehouseStockDeleteInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +function toUnexpectedError(err: unknown): ApiGenericUserError { + return { + code: "UNEXPECTED_ERROR", + message: err instanceof Error ? err.message : "An unexpected error occurred", + }; +} + +export function useDeleteWarehouseStock(): UseDeleteWarehouseStockReturn { + const [deleteWarehouseStockMutation, { loading, error, reset }] = useMutation< + WarehouseStockDeleteMutationData, + WarehouseStockDeleteMutationVariables + >(WAREHOUSE_STOCK_DELETE_MUTATION); + + const deleteWarehouseStock = useCallback( + async ( + input: ApiWarehouseStockDeleteInput, + ): Promise => { + try { + const result = await deleteWarehouseStockMutation({ + variables: { input }, + }); + const payload = + result.data?.inventoryMutation.warehouseStockDelete ?? null; + + return { + deletedWarehouseStockIds: payload?.deletedWarehouseStockIds ?? [], + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + return { + deletedWarehouseStockIds: [], + userErrors: [toUnexpectedError(err)], + }; + } + }, + [deleteWarehouseStockMutation], + ); + + return { + deleteWarehouseStock, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/inventory/hooks/use-inventory-edit-store.ts b/admin/src/domains/inventory/inventory/hooks/use-inventory-edit-store.ts index 54fadb97a..87e187353 100644 --- a/admin/src/domains/inventory/inventory/hooks/use-inventory-edit-store.ts +++ b/admin/src/domains/inventory/inventory/hooks/use-inventory-edit-store.ts @@ -1,30 +1,44 @@ import { create } from "zustand"; -export type EditableField = "onHand" | "unavailable"; +export type EditableField = "sku" | "onHand" | "unavailable"; +export type EditableValue = string | number | null; -interface FieldEdit { - originalValue: number; - currentValue: number; +export interface FieldEdit { + originalValue: EditableValue; + currentValue: EditableValue; } -type ItemEdits = Partial>; +export type ItemEdits = Partial>; + +export type InventorySubmitError = { + message: string; + code?: string | null; + field?: readonly string[] | null; +}; interface InventoryEditStore { // Current edits - source of truth for pending changes edits: Record; + rowErrors: Record; + submitErrors: InventorySubmitError[]; status: "idle" | "saving"; // Actions setFieldValue: ( itemId: string, field: EditableField, - originalValue: number, - newValue: number + originalValue: EditableValue, + newValue: EditableValue ) => void; discardAll: () => void; discardItem: (itemId: string) => void; startSaving: () => void; - onSaveSuccess: () => void; + finishSaving: () => void; + onSubmitAccepted: () => void; + setRowErrors: (itemId: string, errors: InventorySubmitError[]) => void; + clearRowErrors: (itemId: string) => void; + setSubmitErrors: (errors: InventorySubmitError[]) => void; + clearSubmitErrors: () => void; // Selectors hasChanges: () => boolean; @@ -36,6 +50,8 @@ interface InventoryEditStore { export const useInventoryEditStore = create((set, get) => ({ edits: {}, + rowErrors: {}, + submitErrors: [], status: "idle", setFieldValue: (itemId, field, originalValue, newValue) => { @@ -48,7 +64,13 @@ export const useInventoryEditStore = create((set, get) => ({ // If no more edits for this item, remove the item entry if (Object.keys(itemEdits).length === 0) { const { [itemId]: _, ...rest } = state.edits; - return { edits: rest }; + const { [itemId]: __, ...rowErrors } = state.rowErrors; + + return { + edits: rest, + rowErrors, + submitErrors: [], + }; } return { @@ -56,30 +78,42 @@ export const useInventoryEditStore = create((set, get) => ({ ...state.edits, [itemId]: itemEdits, }, + rowErrors: Object.fromEntries( + Object.entries(state.rowErrors).filter(([id]) => id !== itemId), + ), + submitErrors: [], }; }); return; } - set((state) => ({ - edits: { - ...state.edits, - [itemId]: { - ...state.edits[itemId], - [field]: { originalValue, currentValue: newValue }, + set((state) => { + const { [itemId]: _, ...rowErrors } = state.rowErrors; + + return { + edits: { + ...state.edits, + [itemId]: { + ...state.edits[itemId], + [field]: { originalValue, currentValue: newValue }, + }, }, - }, - })); + rowErrors, + submitErrors: [], + }; + }); }, discardAll: () => { - set({ edits: {}, status: "idle" }); + set({ edits: {}, rowErrors: {}, submitErrors: [], status: "idle" }); }, discardItem: (itemId) => { set((state) => { const { [itemId]: _, ...rest } = state.edits; - return { edits: rest }; + const { [itemId]: __, ...rowErrors } = state.rowErrors; + + return { edits: rest, rowErrors }; }); }, @@ -87,8 +121,45 @@ export const useInventoryEditStore = create((set, get) => ({ set({ status: "saving" }); }, - onSaveSuccess: () => { - set({ edits: {}, status: "idle" }); + finishSaving: () => { + set({ status: "idle" }); + }, + + onSubmitAccepted: () => { + set({ edits: {}, rowErrors: {}, submitErrors: [], status: "idle" }); + }, + + setRowErrors: (itemId, errors) => { + set((state) => { + if (errors.length === 0) { + const { [itemId]: _, ...rowErrors } = state.rowErrors; + + return { rowErrors }; + } + + return { + rowErrors: { + ...state.rowErrors, + [itemId]: errors, + }, + }; + }); + }, + + clearRowErrors: (itemId) => { + set((state) => { + const { [itemId]: _, ...rowErrors } = state.rowErrors; + + return { rowErrors }; + }); + }, + + setSubmitErrors: (errors) => { + set({ submitErrors: errors }); + }, + + clearSubmitErrors: () => { + set({ submitErrors: [] }); }, hasChanges: () => Object.keys(get().edits).length > 0, diff --git a/admin/src/domains/inventory/inventory/hooks/use-inventory-items.ts b/admin/src/domains/inventory/inventory/hooks/use-inventory-items.ts new file mode 100644 index 000000000..4ad9a216b --- /dev/null +++ b/admin/src/domains/inventory/inventory/hooks/use-inventory-items.ts @@ -0,0 +1,141 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import type { + ApiInventoryItem, + ApiInventoryItemConnection, + ApiInventoryItemInventoryItemsMetaInput, + ApiInventoryItemOrderByInput, + ApiInventoryItemWhereInput, + ApiPageInfo, +} from "@/graphql/types"; +import { InventoryItemWarehouseScopeMode } from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { INVENTORY_ITEMS_QUERY } from "../graphql"; +import type { + InventoryItemsQueryData, + InventoryItemsQueryVariables, +} from "../graphql"; +import { + mapInventoryVariantEdgesToRows, + type InventoryVariantRow, +} from "../mappers"; + +export interface UseInventoryItemsOptions + extends RelayCursorPaginationVariables { + where?: ApiInventoryItemWhereInput | null; + orderBy?: ApiInventoryItemOrderByInput[] | null; + meta?: ApiInventoryItemInventoryItemsMetaInput | null; + warehouseId?: string | null; + skip?: boolean; +} + +export interface UseInventoryItemsReturn { + rows: InventoryVariantRow[]; + connection: ApiInventoryItemConnection | null; + activeWarehouseId: string | null; + pageInfo: ApiPageInfo | null; + totalCount: number; + loading: boolean; + error: Error | null; + canEdit: boolean; + readOnlyReason: string | null; + refetch: () => Promise; +} + +function buildWarehouseScopeMeta( + warehouseId: string | null, +): ApiInventoryItemInventoryItemsMetaInput | null { + if (!warehouseId) { + return null; + } + + return { + warehouseScope: { + mode: InventoryItemWarehouseScopeMode.Include, + referenceIds: [warehouseId], + }, + }; +} + +export function useInventoryItems({ + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + meta = null, + warehouseId = null, + skip = false, +}: UseInventoryItemsOptions): UseInventoryItemsReturn { + const activeWarehouseId = warehouseId; + const inventoryItemsMeta = useMemo( + () => meta ?? buildWarehouseScopeMeta(activeWarehouseId), + [activeWarehouseId, meta], + ); + const inventoryItemsVariables = useMemo( + () => ({ + first, + after, + last, + before, + where, + orderBy, + ...(inventoryItemsMeta ? { meta: inventoryItemsMeta } : {}), + }), + [ + after, + before, + first, + inventoryItemsMeta, + last, + orderBy, + where, + ], + ); + + const inventoryResult = useRelayConnectionQuery< + InventoryItemsQueryData, + InventoryItemsQueryVariables, + ApiInventoryItem, + ApiInventoryItemConnection + >({ + query: INVENTORY_ITEMS_QUERY, + variables: inventoryItemsVariables, + skip, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.inventoryQuery.inventoryItems, + }); + + const connection = inventoryResult.connection; + const rows = useMemo( + () => + connection + ? mapInventoryVariantEdgesToRows(connection.edges, activeWarehouseId) + : [], + [activeWarehouseId, connection], + ); + + const refetch = useCallback(async () => { + await inventoryResult.refetch(); + }, [inventoryResult]); + + const readOnlyReason = activeWarehouseId + ? null + : "Select a warehouse to edit inventory."; + + return { + rows, + connection, + activeWarehouseId, + pageInfo: inventoryResult.pageInfo, + totalCount: inventoryResult.totalCount, + loading: inventoryResult.loading, + error: inventoryResult.error, + canEdit: Boolean(activeWarehouseId), + readOnlyReason, + refetch, + }; +} diff --git a/admin/src/domains/inventory/inventory/hooks/use-inventory-variants.ts b/admin/src/domains/inventory/inventory/hooks/use-inventory-variants.ts new file mode 100644 index 000000000..0b4aa85b2 --- /dev/null +++ b/admin/src/domains/inventory/inventory/hooks/use-inventory-variants.ts @@ -0,0 +1,5 @@ +export { + useInventoryItems as useInventoryVariants, + type UseInventoryItemsOptions as UseInventoryVariantsOptions, + type UseInventoryItemsReturn as UseInventoryVariantsReturn, +} from "./use-inventory-items"; diff --git a/admin/src/domains/inventory/inventory/hooks/use-inventory.ts b/admin/src/domains/inventory/inventory/hooks/use-inventory.ts index f1ef453fb..be2932cc3 100644 --- a/admin/src/domains/inventory/inventory/hooks/use-inventory.ts +++ b/admin/src/domains/inventory/inventory/hooks/use-inventory.ts @@ -1,60 +1,7 @@ -import { useState, useEffect, useCallback } from "react"; -import { - mockInventoryList, - type IInventoryListItem, -} from "@/mocks/inventory/inventory-list"; +"use client"; -interface UseInventoryOptions { - /** Simulated delay in milliseconds */ - delay?: number; -} - -interface UseInventoryReturn { - data: IInventoryListItem[]; - isLoading: boolean; - error: Error | null; - refetch: () => Promise; -} - -/** - * Hook that simulates an API call to fetch inventory - */ -export function useInventory( - options: UseInventoryOptions = {} -): UseInventoryReturn { - const { delay = 500 } = options; - - const [data, setData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchInventory = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, delay)); - - // Simulate API response - setData(mockInventoryList); - } catch (err) { - setError( - err instanceof Error ? err : new Error("Failed to fetch inventory") - ); - } finally { - setIsLoading(false); - } - }, [delay]); - - useEffect(() => { - fetchInventory(); - }, [fetchInventory]); - - return { - data, - isLoading, - error, - refetch: fetchInventory, - }; -} +export { + useInventoryItems as useInventory, + type UseInventoryItemsOptions as UseInventoryOptions, + type UseInventoryItemsReturn as UseInventoryReturn, +} from "./use-inventory-items"; diff --git a/admin/src/domains/inventory/inventory/hooks/use-save-inventory-variant-edits.ts b/admin/src/domains/inventory/inventory/hooks/use-save-inventory-variant-edits.ts new file mode 100644 index 000000000..c25556649 --- /dev/null +++ b/admin/src/domains/inventory/inventory/hooks/use-save-inventory-variant-edits.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiBulkUpdateUserError, + ApiProductBulkUpdateInput, + BulkUpdateJobStatus, +} from "@/graphql/types"; +import { INVENTORY_PRODUCT_BULK_UPDATE_MUTATION } from "../graphql"; +import type { + InventoryProductBulkUpdateMutationData, + InventoryProductBulkUpdateMutationVariables, +} from "../graphql"; + +const IDEMPOTENCY_KEY_HEADER = "x-idempotency-key"; + +export interface SaveInventoryVariantEditsResult { + jobId: string | null; + status: BulkUpdateJobStatus | null; + userErrors: ApiBulkUpdateUserError[]; +} + +function createIdempotencyKey() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + + return `inventory-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function useSaveInventoryVariantEdits() { + const [mutate, { loading, error, reset }] = useMutation< + InventoryProductBulkUpdateMutationData, + InventoryProductBulkUpdateMutationVariables + >(INVENTORY_PRODUCT_BULK_UPDATE_MUTATION); + + const saveInventoryVariantEdits = useCallback( + async ( + input: ApiProductBulkUpdateInput, + ): Promise => { + const result = await mutate({ + variables: { input }, + context: { + headers: { + [IDEMPOTENCY_KEY_HEADER]: createIdempotencyKey(), + }, + }, + }); + + const payload = result.data?.catalogMutation.productBulkUpdate; + + return { + jobId: payload?.job?.id ?? null, + status: payload?.job?.status ?? null, + userErrors: payload?.userErrors ?? [], + }; + }, + [mutate], + ); + + return { + saveInventoryVariantEdits, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/inventory/mappers/index.ts b/admin/src/domains/inventory/inventory/mappers/index.ts new file mode 100644 index 000000000..6d775b1d9 --- /dev/null +++ b/admin/src/domains/inventory/inventory/mappers/index.ts @@ -0,0 +1,3 @@ +export * from "./inventory-variant-row.mapper"; +export * from "./inventory-variant-sort.mapper"; +export * from "./inventory-variant-edit.mapper"; diff --git a/admin/src/domains/inventory/inventory/mappers/inventory-variant-edit.mapper.ts b/admin/src/domains/inventory/inventory/mappers/inventory-variant-edit.mapper.ts new file mode 100644 index 000000000..ca9b57fc0 --- /dev/null +++ b/admin/src/domains/inventory/inventory/mappers/inventory-variant-edit.mapper.ts @@ -0,0 +1,204 @@ +import type { + ApiProductBulkUpdateInput, + ApiVariantOperationInput, +} from "@/graphql/types"; +import { VariantOperationAction } from "@/graphql/types"; +import type { + InventorySubmitError, + ItemEdits, +} from "../hooks/use-inventory-edit-store"; +import type { InventoryVariantRow } from "./inventory-variant-row.mapper"; + +const MAX_PRODUCTS_PER_BULK_UPDATE = 100; +const MAX_VARIANT_OPERATIONS_PER_BULK_UPDATE = 500; + +export interface InventoryVariantEditMappingResult { + input: ApiProductBulkUpdateInput; + rowErrors: Record; + submitErrors: InventorySubmitError[]; + operationsCount: number; +} + +export interface InventoryVariantSelection { + productId: string; + productRevision: number; + variantId: string; +} + +export function mapInventoryVariantSelectionsToProductBulkUpdateInput( + variants: InventoryVariantSelection[], + warehouseId: string, +): ApiProductBulkUpdateInput { + const variantsByProduct = new Map< + string, + { expectedRevision: number; variants: ApiVariantOperationInput[] } + >(); + + for (const variant of variants) { + const productGroup = variantsByProduct.get(variant.productId) ?? { + expectedRevision: variant.productRevision, + variants: [], + }; + productGroup.variants.push({ + action: VariantOperationAction.Update, + variantId: variant.variantId, + inventory: { + warehouseId, + onHand: 0, + unavailable: 0, + }, + }); + variantsByProduct.set(variant.productId, productGroup); + } + + return { + products: [...variantsByProduct.entries()].map(([productId, group]) => ({ + productId, + expectedRevision: group.expectedRevision, + operations: { + variants: group.variants, + }, + })), + }; +} + +function hasPendingFieldEdits(edits: ItemEdits | undefined): edits is ItemEdits { + return Boolean(edits && Object.keys(edits).length > 0); +} + +function addRowError( + rowErrors: Record, + rowId: string, + error: InventorySubmitError, +) { + rowErrors[rowId] = [...(rowErrors[rowId] ?? []), error]; +} + +export function mapInventoryVariantEditsToProductBulkUpdateInput( + rows: InventoryVariantRow[], + edits: Record, + activeWarehouseId: string | null, +): InventoryVariantEditMappingResult { + const rowsById = new Map(rows.map((row) => [row.id, row])); + const rowErrors: Record = {}; + const submitErrors: InventorySubmitError[] = []; + const variantsByProduct = new Map< + string, + { expectedRevision: number; variants: ApiVariantOperationInput[] } + >(); + + for (const [rowId, rowEdits] of Object.entries(edits)) { + if (!hasPendingFieldEdits(rowEdits)) { + continue; + } + + const row = rowsById.get(rowId); + + if (!row) { + submitErrors.push({ + message: "Edited inventory row is no longer loaded.", + code: "INVENTORY_ROW_NOT_LOADED", + }); + continue; + } + + if (!row.inventoryItemId) { + addRowError(rowErrors, row.id, { + message: "Inventory item is missing for this variant.", + code: "INVENTORY_ITEM_MISSING", + field: ["products", "operations", "variants"], + }); + continue; + } + + const warehouseId = row.warehouseId ?? activeWarehouseId; + + if (!warehouseId) { + addRowError(rowErrors, row.id, { + message: "Select a warehouse to edit inventory.", + code: "WAREHOUSE_SCOPE_REQUIRED", + field: [ + "products", + "operations", + "variants", + "inventory", + "warehouseId", + ], + }); + continue; + } + + const nextOnHand = rowEdits.onHand?.currentValue ?? row.onHand; + const nextUnavailable = + rowEdits.unavailable?.currentValue ?? row.unavailable; + const nextSku = + rowEdits.sku?.currentValue === undefined + ? row.sku + : rowEdits.sku.currentValue; + + const variantInput: ApiVariantOperationInput = { + action: VariantOperationAction.Update, + variantId: row.variantId, + inventory: { + warehouseId, + onHand: Number(nextOnHand), + sku: nextSku == null ? null : String(nextSku), + unavailable: Number(nextUnavailable), + }, + }; + + const productGroup = variantsByProduct.get(row.productId) ?? { + expectedRevision: row.productRevision, + variants: [], + }; + productGroup.variants.push(variantInput); + variantsByProduct.set(row.productId, productGroup); + } + + const operationsCount = [...variantsByProduct.values()].reduce( + (count, group) => count + group.variants.length, + 0, + ); + + if (variantsByProduct.size > MAX_PRODUCTS_PER_BULK_UPDATE) { + submitErrors.push({ + message: `Bulk update is limited to ${MAX_PRODUCTS_PER_BULK_UPDATE} products.`, + code: "PRODUCT_BULK_UPDATE_PRODUCT_LIMIT_EXCEEDED", + field: ["products"], + }); + } + + if (operationsCount > MAX_VARIANT_OPERATIONS_PER_BULK_UPDATE) { + submitErrors.push({ + message: `Bulk update is limited to ${MAX_VARIANT_OPERATIONS_PER_BULK_UPDATE} variant operations.`, + code: "PRODUCT_BULK_UPDATE_OPERATION_LIMIT_EXCEEDED", + field: ["products", "operations", "variants"], + }); + } + + if ( + operationsCount === 0 && + submitErrors.length === 0 && + Object.keys(rowErrors).length === 0 + ) { + submitErrors.push({ + message: "There are no editable inventory changes to save.", + code: "NO_INVENTORY_CHANGES", + }); + } + + return { + input: { + products: [...variantsByProduct.entries()].map(([productId, variants]) => ({ + productId, + expectedRevision: variants.expectedRevision, + operations: { + variants: variants.variants, + }, + })), + }, + rowErrors, + submitErrors, + operationsCount, + }; +} diff --git a/admin/src/domains/inventory/inventory/mappers/inventory-variant-row.mapper.ts b/admin/src/domains/inventory/inventory/mappers/inventory-variant-row.mapper.ts new file mode 100644 index 000000000..104606552 --- /dev/null +++ b/admin/src/domains/inventory/inventory/mappers/inventory-variant-row.mapper.ts @@ -0,0 +1,117 @@ +import type { ApiInventoryItemEdge } from "@/graphql/types"; + +export interface InventoryVariantRow { + id: string; + variantId: string; + productId: string; + productRevision: number; + productTitle: string; + productHandle: string | null; + variantTitle: string | null; + variantHandle: string; + isDefault: boolean; + imageUrl: string | null; + sku: string | null; + inventoryItemId: string | null; + warehouseStockId: string | null; + warehouseId: string | null; + onHand: number; + unavailable: number; + reserved: number; + available: number; + trackInventory: boolean; + continueSellingWhenOutOfStock: boolean; + cursor: string; + readOnly: boolean; + readOnlyReason: string | null; +} + +function getPrimaryImageUrl(edge: ApiInventoryItemEdge): string | null { + const primaryMedia = edge.node.variant.media.reduce< + ApiInventoryItemEdge["node"]["variant"]["media"][number] | null + >((current, item) => { + if (!current) { + return item; + } + + return item.sortIndex < current.sortIndex ? item : current; + }, null); + + return primaryMedia?.file.url ?? null; +} + +function sumStockField( + stock: ApiInventoryItemEdge["node"]["stock"], + field: + | "quantityOnHand" + | "unavailableQuantity" + | "reservedQuantity" + | "availableForSale", +) { + return stock.reduce((total, item) => total + item[field], 0); +} + +export function mapInventoryVariantEdgeToRow( + edge: ApiInventoryItemEdge, + warehouseId: string | null, +): InventoryVariantRow { + const inventoryItem = edge.node; + const variant = inventoryItem.variant; + const selectedStock = warehouseId + ? inventoryItem.stock.find((stock) => stock.warehouseId === warehouseId) ?? + null + : null; + + const onHand = selectedStock + ? selectedStock.quantityOnHand + : sumStockField(inventoryItem.stock, "quantityOnHand"); + const unavailable = selectedStock + ? selectedStock.unavailableQuantity + : sumStockField(inventoryItem.stock, "unavailableQuantity"); + const reserved = selectedStock + ? selectedStock.reservedQuantity + : sumStockField(inventoryItem.stock, "reservedQuantity"); + const available = selectedStock + ? selectedStock.availableForSale + : inventoryItem.totalAvailable; + + const readOnlyReason = !warehouseId + ? "Select a warehouse to edit inventory." + : null; + + return { + id: inventoryItem.id, + variantId: variant.id, + productId: variant.product.id, + productRevision: variant.product.revision, + productTitle: variant.product.title, + productHandle: variant.product.handle ?? null, + variantTitle: variant.title ?? null, + variantHandle: variant.handle, + isDefault: variant.isDefault, + imageUrl: getPrimaryImageUrl(edge), + sku: inventoryItem.sku ?? null, + inventoryItemId: inventoryItem.id, + warehouseStockId: selectedStock?.id ?? null, + warehouseId: selectedStock?.warehouseId ?? warehouseId, + onHand, + unavailable, + reserved, + available, + trackInventory: inventoryItem.trackInventory, + continueSellingWhenOutOfStock: + inventoryItem.continueSellingWhenOutOfStock, + cursor: edge.cursor, + readOnly: Boolean(readOnlyReason), + readOnlyReason, + }; +} + +export function mapInventoryVariantEdgesToRows( + edges: ApiInventoryItemEdge[], + warehouseId: string | null, +): InventoryVariantRow[] { + return edges.map((edge) => + mapInventoryVariantEdgeToRow(edge, warehouseId), + ); +} diff --git a/admin/src/domains/inventory/inventory/mappers/inventory-variant-sort.mapper.ts b/admin/src/domains/inventory/inventory/mappers/inventory-variant-sort.mapper.ts new file mode 100644 index 000000000..f81aaa2e5 --- /dev/null +++ b/admin/src/domains/inventory/inventory/mappers/inventory-variant-sort.mapper.ts @@ -0,0 +1,50 @@ +import type { ApiVariantOrderByInput } from "@/graphql/types"; +import { + SortDirection as ApiSortDirection, + VariantOrderField, +} from "@/graphql/types"; +import type { SortModel } from "@/hooks/use-grid-sort"; + +const SUPPORTED_SORT_FIELDS: Record = { + variantId: VariantOrderField.Id, + isDefault: VariantOrderField.IsDefault, + createdAt: VariantOrderField.CreatedAt, + updatedAt: VariantOrderField.UpdatedAt, + externalSystem: VariantOrderField.ExternalSystem, + externalId: VariantOrderField.ExternalId, +}; + +const PRODUCT_FIRST_SORT: ApiVariantOrderByInput = { + field: VariantOrderField.ProductId, + direction: ApiSortDirection.Asc, +}; + +const FALLBACK_VARIANT_SORT: ApiVariantOrderByInput = { + field: VariantOrderField.Id, + direction: ApiSortDirection.Asc, +}; + +function toApiSortDirection(sort: SortModel["sort"]): ApiSortDirection { + return sort === "desc" ? ApiSortDirection.Desc : ApiSortDirection.Asc; +} + +export function mapInventoryVariantSortModelToOrderBy( + sortModel: SortModel[], +): ApiVariantOrderByInput[] { + const activeSort = sortModel + .map((sort): ApiVariantOrderByInput | null => { + const field = SUPPORTED_SORT_FIELDS[sort.colId]; + + if (!field) { + return null; + } + + return { + field, + direction: toApiSortDirection(sort.sort), + }; + }) + .find((sort): sort is ApiVariantOrderByInput => sort !== null); + + return [PRODUCT_FIRST_SORT, activeSort ?? FALLBACK_VARIANT_SORT]; +} diff --git a/admin/src/domains/inventory/inventory/page/filter-schema.ts b/admin/src/domains/inventory/inventory/page/filter-schema.ts index afd9e9d3d..2f90976f3 100644 --- a/admin/src/domains/inventory/inventory/page/filter-schema.ts +++ b/admin/src/domains/inventory/inventory/page/filter-schema.ts @@ -2,8 +2,8 @@ import { FilterType, numberOperators, stringOperators, - type IFilterSchema, } from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; export const filterSchema: IFilterSchema[] = [ { @@ -20,7 +20,7 @@ export const filterSchema: IFilterSchema[] = [ description: "Filter by available quantity", type: FilterType.Number, operators: numberOperators, - payloadKey: "available", + payloadKey: "availableForSale", }, { key: "reserved", @@ -28,7 +28,7 @@ export const filterSchema: IFilterSchema[] = [ description: "Filter by reserved quantity", type: FilterType.Number, operators: numberOperators, - payloadKey: "reserved", + payloadKey: "reservedQuantity", }, { key: "unavailable", @@ -36,6 +36,14 @@ export const filterSchema: IFilterSchema[] = [ description: "Filter by unavailable quantity", type: FilterType.Number, operators: numberOperators, - payloadKey: "unavailable", + payloadKey: "unavailableQuantity", + }, + { + key: "onHand", + label: "On hand", + description: "Filter by on hand quantity", + type: FilterType.Number, + operators: numberOperators, + payloadKey: "quantityOnHand", }, ]; diff --git a/admin/src/domains/inventory/inventory/page/page-config.ts b/admin/src/domains/inventory/inventory/page/page-config.ts new file mode 100644 index 000000000..a72750442 --- /dev/null +++ b/admin/src/domains/inventory/inventory/page/page-config.ts @@ -0,0 +1,92 @@ +import type { + FilterTransformer, + OrderByInput, + SortFieldMapping, + UsePageConfigReturn, +} from "@/hooks"; +import { + createGraphqlIntFilterTransformer, + createGraphqlStringFilterTransformer, +} from "@/layouts/filters"; +import type { + ApiInventoryItemOrderByInput, + ApiInventoryItemWhereInput, +} from "@/graphql/types"; +import { InventoryItemOrderField } from "@/graphql/types"; +import type { InventoryItemsQueryVariables } from "../graphql/operation-types"; + +export const inventorySortFieldMapping: SortFieldMapping = + { + productTitle: InventoryItemOrderField.ProductName, + sku: InventoryItemOrderField.Sku, + onHand: InventoryItemOrderField.QuantityOnHand, + unavailable: InventoryItemOrderField.UnavailableQuantity, + reserved: InventoryItemOrderField.ReservedQuantity, + available: InventoryItemOrderField.AvailableForSale, + }; + +export const buildInventorySearchCondition = ( + search: string, +): Partial => ({ + _or: [ + { productName: { _containsi: search } }, + { sku: { _containsi: search } }, + ], +}); + +export const inventoryFilterTransformers: Record< + string, + FilterTransformer +> = { + sku: createGraphqlStringFilterTransformer("sku"), + quantityOnHand: + createGraphqlIntFilterTransformer( + "quantityOnHand", + ), + unavailableQuantity: + createGraphqlIntFilterTransformer( + "unavailableQuantity", + ), + reservedQuantity: + createGraphqlIntFilterTransformer( + "reservedQuantity", + ), + availableForSale: + createGraphqlIntFilterTransformer( + "availableForSale", + ), +}; + +export function buildInventoryItemsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): InventoryItemsQueryVariables { + return { + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: (pageConfig.orderBy ?? null) as + | ApiInventoryItemOrderByInput[] + | null, + meta: null, + }; +} + +export function toInventoryItemsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): InventoryItemsQueryVariables { + return buildInventoryItemsQueryVariables({ + ...pageConfig, + where: pageConfig.where as ApiInventoryItemWhereInput | undefined, + orderBy: pageConfig.orderBy as + | OrderByInput[] + | undefined, + }); +} diff --git a/admin/src/domains/inventory/inventory/page/page.tsx b/admin/src/domains/inventory/inventory/page/page.tsx index 5bd76a83f..3b7425eb9 100644 --- a/admin/src/domains/inventory/inventory/page/page.tsx +++ b/admin/src/domains/inventory/inventory/page/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useMemo, useRef, useCallback } from "react"; -import { Flex, Button, App } from "antd"; -import { DeleteOutlined } from "@ant-design/icons"; +import { useState, useMemo, useRef, useCallback, useEffect } from "react"; +import { Alert, App, Button } from "antd"; +import { LuTrash2 as DeleteOutlined, LuImport as ImportOutlined } from "react-icons/lu"; import { createStyles } from "antd-style"; import { AgGridReact } from "ag-grid-react"; import { @@ -12,21 +12,49 @@ import { RowSelectionModule, GridStateModule, CellEditRequestEvent, + ICellRendererParams, SelectionChangedEvent, + SortChangedEvent, } from "ag-grid-community"; import { DataLayout } from "@/layouts/data"; -import { useFilters, FilterWidget } from "@/layouts/filters"; +import { FilterWidget } from "@/layouts/filters"; import { CursorPagination } from "@/ui-kit/cursor-pagination"; -import { - FloatingPanelStack, - type PanelConfig, - type ActionConfig, -} from "@/ui-kit/floating-panel-stack"; -import { useGridState, useAgGridTheme, useAgGridRowSelection } from "@/hooks"; +import { FloatingPanelStack } from "@/ui-kit/floating-panel-stack"; +import type { ActionConfig } from "@/ui-kit/floating-panel-stack/core/types"; +import type { PanelConfig } from "@/ui-kit/floating-panel-stack/data-page/floating-panel-stack"; +import type { ModulePageProps } from "@/registry"; +import { useWarehouses } from "@/domains/inventory/warehouse/hooks"; +import { useAgGridTheme, useAgGridRowSelection } from "@/hooks"; +import { useInventoryRelayListPage } from "@/domains/inventory/hooks"; +import type { + ApiInventoryItemWhereInput, + ApiWarehouseWhereInput, +} from "@/graphql/types"; +import { InventoryItemOrderField } from "@/graphql/types"; import { filterSchema } from "./filter-schema"; -import { useInventory, useInventoryEditStore } from "../hooks"; +import { + buildInventoryItemsQueryVariables, + buildInventorySearchCondition, + inventoryFilterTransformers, + inventorySortFieldMapping, +} from "./page-config"; +import { + useInventoryEditStore, + type EditableField, + useCreateWarehouseStock, + useDeleteWarehouseStock, + useInventoryItems, + useSaveInventoryVariantEdits, + type UseInventoryItemsOptions, + type InventorySubmitError, +} from "../hooks"; +import { + mapInventoryVariantEditsToProductBulkUpdateInput, + type InventoryVariantRow, +} from "../mappers"; +import { useVariantPicker } from "@/shared/components/entity-picker-modal/hooks/use-entity-picker"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; import { validateFieldChange } from "@/shared/utils/inventory"; -import type { IInventoryListItem } from "@/mocks/inventory/inventory-list"; import { CalculatedAvailableCell, ProductCellRenderer, @@ -34,6 +62,7 @@ import { OnHandCellRenderer, UnavailableCellRenderer, } from "../components"; +import { Dash } from "@/shared/components/editor-grid"; ModuleRegistry.registerModules([ AllCommunityModule, @@ -54,6 +83,14 @@ const useStyles = createStyles(({ token }) => ({ opacity: 1, }, }, + "& .ec-dash": { + display: "inline-block", + width: 24, + height: 4, + backgroundColor: token.colorBorder, + borderRadius: 2, + verticalAlign: "middle", + }, }, gridContainer: { height: "100%", @@ -61,75 +98,409 @@ const useStyles = createStyles(({ token }) => ({ display: "flex", flexDirection: "column", }, + errorPanel: { + width: 520, + maxWidth: "calc(100vw - 48px)", + }, })); -export default function InventoryPage() { +function toSubmitError(error: unknown): InventorySubmitError { + return { + message: + error instanceof Error + ? error.message + : "Failed to submit inventory changes.", + code: "INVENTORY_SUBMIT_FAILED", + }; +} + +function getFirstStoredError( + submitErrors: InventorySubmitError[], + rowErrors: Record, +) { + return submitErrors[0] ?? Object.values(rowErrors).flat()[0] ?? null; +} + +const SkuCellRenderer = ({ + data, + value, +}: ICellRendererParams) => { + const { getFieldEdit } = useInventoryEditStore(); + + if (!data) return null; + + const fieldEdit = getFieldEdit(data.id, "sku"); + + if (fieldEdit) { + const originalValue = + fieldEdit.originalValue == null ? ( + + ) : ( + String(fieldEdit.originalValue) + ); + const currentValue = + fieldEdit.currentValue == null ? ( + + ) : ( + String(fieldEdit.currentValue) + ); + + return ( + + {originalValue} → {currentValue} + + ); + } + + return value == null ? : {String(value)}; +}; + +interface InventoryImportVariantEntity extends IPickableEntity { + variantId: string; +} + +function decodePathParam(value: string | null): string | null { + if (!value) { + return null; + } + + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export default function InventoryPage({ pathParams }: ModulePageProps) { const { styles } = useStyles(); const agGridTheme = useAgGridTheme(); - const gridRef = useRef>(null); - const [searchValue, setSearchValue] = useState(""); - const { widgetProps } = useFilters({ schema: filterSchema }); - const { data: serverData, refetch } = useInventory(); - const { initialState, onStateUpdated } = useGridState({ - storageKey: "inventory-grid-state", - }); + const gridRef = useRef>(null); + const restoringSortRef = useRef(false); + const warehouseId = decodePathParam( + typeof pathParams.warehouseId === "string" ? pathParams.warehouseId : null, + ); const { discardAll, startSaving, - onSaveSuccess, + finishSaving, + onSubmitAccepted, setFieldValue, + setRowErrors, + clearRowErrors, + setSubmitErrors, + clearSubmitErrors, edits, + rowErrors, + submitErrors, status, } = useInventoryEditStore(); const { message } = App.useApp(); + const { + createWarehouseStock, + loading: creatingWarehouseStock, + } = useCreateWarehouseStock(); + const { + deleteWarehouseStock, + loading: deletingWarehouseStock, + } = useDeleteWarehouseStock(); + + const hasUnsavedChanges = Object.keys(edits).length > 0; + const canNavigate = !hasUnsavedChanges && status !== "saving"; + const titleWarehouseWhere = useMemo( + () => (warehouseId ? { id: { _eq: warehouseId } } : null), + [warehouseId], + ); + const { warehouses: titleWarehouses } = useWarehouses({ + first: 1, + where: titleWarehouseWhere, + skip: !warehouseId, + }); + const titleWarehouse = titleWarehouses[0] ?? null; + const pageTitle = warehouseId + ? titleWarehouse?.name || titleWarehouse?.code || "Inventory" + : "All Inventory"; + const buildInventoryItemsVariables = useCallback( + ( + pageConfig: Parameters[0], + ): UseInventoryItemsOptions => ({ + ...buildInventoryItemsQueryVariables(pageConfig), + warehouseId, + }), + [warehouseId], + ); + + const { + pageConfig, + listResult, + items: serverData, + pageInfo, + totalCount, + loading, + error, + refetch, + handleNextPage, + handlePrevPage, + } = useInventoryRelayListPage< + InventoryVariantRow, + ApiInventoryItemWhereInput, + InventoryItemOrderField, + UseInventoryItemsOptions, + ReturnType + >({ + gridRef, + storageKey: "inventory-grid-state", + filterSchema, + sortFieldMapping: inventorySortFieldMapping, + defaultPageSize: 20, + buildSearchCondition: buildInventorySearchCondition, + filterTransformers: inventoryFilterTransformers, + buildQueryVariables: buildInventoryItemsVariables, + useListQuery: useInventoryItems, + getItems: (result) => result.rows, + }); + const { activeWarehouseId, canEdit } = listResult; + const { saveInventoryVariantEdits } = useSaveInventoryVariantEdits(); + + const handleImportVariants = useCallback( + async (entities: IPickableEntity[]) => { + if (creatingWarehouseStock) { + return; + } + + if (!activeWarehouseId) { + message.error("Select a warehouse to import variants."); + return; + } + + const variants = entities as InventoryImportVariantEntity[]; + if (variants.length === 0) { + return; + } + + try { + const result = await createWarehouseStock({ + items: variants.map((variant) => ({ + variantId: variant.variantId, + warehouseId: activeWarehouseId, + })), + }); + const firstError = result.userErrors[0] ?? null; + + if (firstError) { + message.error(firstError.message); + return; + } + + const createdCount = result.warehouseStocks.length; + if (createdCount === 0) { + message.error("Variant import was not accepted."); + return; + } + + message.success( + createdCount === 1 + ? "Variant imported." + : `${createdCount} variants imported.`, + ); + await refetch(); + } catch (importError) { + message.error(toSubmitError(importError).message); + } + }, + [ + activeWarehouseId, + createWarehouseStock, + creatingWarehouseStock, + message, + refetch, + ], + ); + const { openPicker: openVariantPicker } = useVariantPicker({ + selectionMode: "multi", + queryMeta: { warehouseId: activeWarehouseId }, + onConfirm: handleImportVariants, + }); + + const handleOpenImportPicker = useCallback(() => { + if (!activeWarehouseId) { + message.error("Select a warehouse to import variants."); + return; + } + + openVariantPicker(); + }, [activeWarehouseId, message, openVariantPicker]); + + const handleSortChanged = useCallback( + (event: SortChangedEvent) => { + if (restoringSortRef.current) { + restoringSortRef.current = false; + return; + } + + if (!canNavigate) { + message.warning("Save or discard changes to sort inventory."); + restoringSortRef.current = true; + event.api.applyColumnState({ + state: pageConfig.sortModel.map((sort, index) => ({ + colId: sort.colId, + sort: sort.sort, + sortIndex: index, + })), + defaultState: { sort: null }, + }); + return; + } + + pageConfig.onSortChanged(event); + }, + [canNavigate, message, pageConfig], + ); // Selection state const [selectedIds, setSelectedIds] = useState([]); + useEffect(() => { + discardAll(); + setSelectedIds([]); + }, [discardAll, warehouseId]); + // Compute display data by merging server data with pending edits const displayData = useMemo(() => { + if (!canEdit) { + return serverData; + } + return serverData.map((item) => { const itemEdits = edits[item.id]; if (!itemEdits) return item; - const onHand = itemEdits.onHand?.currentValue ?? item.onHand; - const unavailable = - itemEdits.unavailable?.currentValue ?? item.unavailable; + const sku = itemEdits.sku?.currentValue ?? item.sku; + const onHand = Number(itemEdits.onHand?.currentValue ?? item.onHand); + const unavailable = Number( + itemEdits.unavailable?.currentValue ?? item.unavailable, + ); const available = onHand - unavailable - item.reserved; return { ...item, + sku: sku == null ? null : String(sku), onHand, unavailable, available, }; }); - }, [serverData, edits]); + }, [canEdit, serverData, edits]); const handleDiscard = useCallback(() => { discardAll(); }, [discardAll]); + const clearStoredErrors = useCallback(() => { + clearSubmitErrors(); + Object.keys(rowErrors).forEach((rowId) => clearRowErrors(rowId)); + }, [clearRowErrors, clearSubmitErrors, rowErrors]); + const handleSave = useCallback(async () => { startSaving(); + clearStoredErrors(); - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1000)); + if (!activeWarehouseId) { + const readOnlyError = { + message: "Select a warehouse to edit inventory.", + code: "WAREHOUSE_SCOPE_REQUIRED", + }; - // In a real app, send edits to API here + setSubmitErrors([readOnlyError]); + finishSaving(); + message.error(readOnlyError.message); + return; + } - onSaveSuccess(); - await refetch(); - }, [startSaving, onSaveSuccess, refetch]); + const mapping = mapInventoryVariantEditsToProductBulkUpdateInput( + serverData, + edits, + activeWarehouseId, + ); + const hasRowErrors = Object.keys(mapping.rowErrors).length > 0; + const hasSubmitErrors = mapping.submitErrors.length > 0; + + for (const [rowId, errors] of Object.entries(mapping.rowErrors)) { + setRowErrors(rowId, errors); + } + + if (hasRowErrors || hasSubmitErrors) { + setSubmitErrors(mapping.submitErrors); + finishSaving(); + + const firstError = + mapping.submitErrors[0] ?? + Object.values(mapping.rowErrors).flat()[0] ?? + null; + message.error( + firstError?.message ?? "Fix inventory errors before saving.", + ); + return; + } + + try { + const result = await saveInventoryVariantEdits(mapping.input); + const apiErrors = result.userErrors.map((userError) => ({ + message: userError.message, + code: userError.code, + field: userError.field, + })); + + if (!result.jobId && apiErrors.length > 0) { + setSubmitErrors(apiErrors); + finishSaving(); + message.error(apiErrors[0].message); + return; + } + + if (!result.jobId) { + setSubmitErrors([ + { + message: "Inventory update was not accepted.", + code: "PRODUCT_BULK_UPDATE_NOT_ACCEPTED", + }, + ]); + finishSaving(); + message.error("Inventory update was not accepted."); + return; + } + + onSubmitAccepted(); + message.success(`Inventory update accepted. Job ${result.jobId}`); + await refetch(); + } catch (submitError) { + const normalizedError = toSubmitError(submitError); + setSubmitErrors([normalizedError]); + finishSaving(); + message.error(normalizedError.message); + } + }, [ + clearStoredErrors, + activeWarehouseId, + edits, + finishSaving, + message, + onSubmitAccepted, + refetch, + saveInventoryVariantEdits, + serverData, + setRowErrors, + setSubmitErrors, + startSaving, + ]); // Handle selection changes const handleSelectionChanged = useCallback( - (event: SelectionChangedEvent) => { + (event: SelectionChangedEvent) => { const selectedRows = event.api.getSelectedRows(); setSelectedIds(selectedRows.map((row) => row.id)); }, - [] + [], ); // Deselect all rows @@ -139,33 +510,130 @@ export default function InventoryPage() { }, []); // Delete selected items - const handleDeleteSelected = useCallback(() => { - // TODO: Implement delete mutation - console.log("Delete items:", selectedIds); + const handleDeleteSelected = useCallback(async () => { + if (deletingWarehouseStock || status === "saving") { + return; + } + + if (hasUnsavedChanges) { + message.warning("Save or discard changes before deleting inventory."); + return; + } + + if (!activeWarehouseId) { + message.error("Select a warehouse to delete inventory."); + return; + } + + const selectedRows = selectedIds + .map((id) => serverData.find((item) => item.id === id) ?? null) + .filter((item): item is InventoryVariantRow => item !== null); + + if (selectedRows.length === 0) { + return; + } + + const nonEmptyRows = selectedRows.filter( + (row) => row.onHand !== 0 || row.reserved !== 0 || row.unavailable !== 0, + ); + + if (nonEmptyRows.length > 0) { + message.warning( + nonEmptyRows.length === 1 + ? "Set stock quantities to 0 before deleting this inventory item." + : "Set stock quantities to 0 before deleting selected inventory items.", + ); + return; + } + + const result = await deleteWarehouseStock({ + items: selectedRows.map((row) => ({ + variantId: row.variantId, + warehouseId: row.warehouseId ?? activeWarehouseId, + })), + }); + const firstError = result.userErrors[0] ?? null; + + if (firstError) { + message.error(firstError.message); + return; + } + + const deletedCount = result.deletedWarehouseStockIds.length; + if (deletedCount === 0) { + message.error("Inventory item was not deleted."); + return; + } + + message.success( + deletedCount === 1 + ? "Inventory item deleted." + : `${deletedCount} inventory items deleted.`, + ); deselectAll(); - }, [selectedIds, deselectAll]); + await refetch(); + }, [ + activeWarehouseId, + deleteWarehouseStock, + deletingWarehouseStock, + deselectAll, + hasUnsavedChanges, + message, + refetch, + selectedIds, + serverData, + status, + ]); const handleCellEditRequest = useCallback( - (event: CellEditRequestEvent) => { + (event: CellEditRequestEvent) => { const { data, colDef, newValue } = event; if (!data) return; - const field = colDef.field as "onHand" | "unavailable"; + if (data.readOnly || !canEdit) { + message.error( + data.readOnlyReason ?? "This inventory row is read-only.", + ); + return; + } + + const field = colDef.field as EditableField; + if (field === "sku") { + const serverItem = serverData.find((item) => item.id === data.id); + if (!serverItem) return; + + const nextSku = String(newValue ?? "").trim() || null; + setFieldValue(data.id, "sku", serverItem.sku, nextSku); + return; + } + if (field !== "onHand" && field !== "unavailable") return; + const parsedValue = + typeof newValue === "number" + ? newValue + : Number.parseInt(String(newValue), 10); + + if (!Number.isInteger(parsedValue)) { + message.error("Inventory quantity must be an integer."); + return; + } + // Find original server data const serverItem = serverData.find((item) => item.id === data.id); if (!serverItem) return; // Get current values (from edits or original server data) const currentEdits = edits[data.id]; - const currentOnHand = - currentEdits?.onHand?.currentValue ?? serverItem.onHand; - const currentUnavailable = - currentEdits?.unavailable?.currentValue ?? serverItem.unavailable; + const currentOnHand = Number( + currentEdits?.onHand?.currentValue ?? serverItem.onHand, + ); + const currentUnavailable = Number( + currentEdits?.unavailable?.currentValue ?? serverItem.unavailable, + ); // Validate using shared validator - const result = validateFieldChange(field, Number(newValue), { + const result = validateFieldChange(field, parsedValue, { onHand: currentOnHand, unavailable: currentUnavailable, reserved: serverItem.reserved, @@ -179,76 +647,81 @@ export default function InventoryPage() { // Store edit in Zustand (original value from server, new value from edit) const originalValue = serverItem[field]; - setFieldValue(data.id, field, originalValue, Number(newValue)); + setFieldValue(data.id, field, originalValue, parsedValue); }, - [message, setFieldValue, edits, serverData] + [canEdit, message, setFieldValue, edits, serverData], ); // Row selection with checkbox isolation const { rowSelection, selectionColumnDef, onCellClicked } = - useAgGridRowSelection(); + useAgGridRowSelection(); - const columnDefs = useMemo[]>( + const columnDefs = useMemo[]>( () => [ { headerName: "Product", - field: "productName", + colId: "productTitle", + field: "productTitle", cellRenderer: ProductCellRenderer, flex: 2, minWidth: 300, }, { headerName: "SKU", + colId: "sku", field: "sku", minWidth: 120, + cellRenderer: SkuCellRenderer, + editable: ({ data }) => canEdit && !data?.readOnly, }, { headerName: "On hand", + colId: "onHand", field: "onHand", cellRenderer: OnHandCellRenderer, cellEditor: "agNumberCellEditor", cellEditorParams: { min: 0 }, - editable: true, + editable: ({ data }) => canEdit && !data?.readOnly, minWidth: 120, - type: "rightAligned", }, { headerName: "Unavailable", + colId: "unavailable", field: "unavailable", cellRenderer: UnavailableCellRenderer, cellEditor: "agNumberCellEditor", cellEditorParams: { min: 0 }, - editable: true, + editable: ({ data }) => canEdit && !data?.readOnly, minWidth: 120, - type: "rightAligned", }, { headerName: "Reserved", + colId: "reserved", field: "reserved", cellRenderer: ReservedCellRenderer, minWidth: 120, - type: "rightAligned", }, { headerName: "Available", + colId: "available", field: "available", cellRenderer: CalculatedAvailableCell, minWidth: 120, flex: 1, - type: "rightAligned", resizable: false, }, ], - [] + [canEdit], ); const defaultColDef = useMemo( () => ({ resizable: true, sortable: true, + comparator: () => 0, cellStyle: { display: "flex", alignItems: "center" }, }), - [] + [], ); // Build selection actions @@ -259,23 +732,47 @@ export default function InventoryPage() { label: "Delete", icon: , danger: true, + loading: deletingWarehouseStock, + disabled: deletingWarehouseStock || status === "saving", onClick: handleDeleteSelected, }, ], - [handleDeleteSelected] + [deletingWarehouseStock, handleDeleteSelected, status], ); // Derive reactive values from edits - const hasUnsavedChanges = Object.keys(edits).length > 0; const changesCount = Object.values(edits).reduce( (count, fields) => count + Object.keys(fields || {}).length, - 0 + 0, + ); + const rowErrorCount = Object.values(rowErrors).reduce( + (count, errors) => count + errors.length, + 0, ); + const firstStoredError = getFirstStoredError(submitErrors, rowErrors); // Build floating panels const panels = useMemo(() => { const result: PanelConfig[] = []; + if (firstStoredError && hasUnsavedChanges) { + result.push({ + type: "custom", + id: "inventory-submit-errors", + render: () => ( + 0 ? `${rowErrorCount} row error(s)` : undefined + } + /> + ), + }); + } + if (hasUnsavedChanges) { result.push({ type: "editing", @@ -287,7 +784,7 @@ export default function InventoryPage() { }); } - if (selectedIds.length > 0) { + if (canEdit && selectedIds.length > 0) { // eslint-disable-next-line react-hooks/refs -- deselectAll is called on click, not during render result.push({ type: "selection", @@ -299,46 +796,64 @@ export default function InventoryPage() { return result; }, [ - hasUnsavedChanges, changesCount, - status, - handleSave, + canEdit, + deselectAll, + firstStoredError, handleDiscard, + handleSave, + hasUnsavedChanges, + rowErrorCount, selectedIds.length, selectionActions, - deselectAll, + status, + styles.errorPanel, ]); - // Block pagination when editing - const canNavigate = !hasUnsavedChanges && status !== "saving"; - return ( - - - + } > } />
    -
    - + {error ? ( + + ) : null} + +
    + ref={gridRef} theme={agGridTheme} rowData={displayData} @@ -346,29 +861,36 @@ export default function InventoryPage() { defaultColDef={defaultColDef} getRowId={(params) => params.data.id} rowHeight={56} - rowSelection={rowSelection} - selectionColumnDef={selectionColumnDef} + loading={loading} + rowSelection={canEdit ? rowSelection : undefined} + selectionColumnDef={canEdit ? selectionColumnDef : undefined} suppressMovableColumns readOnlyEdit onCellEditRequest={handleCellEditRequest} - onCellClicked={onCellClicked} + onCellClicked={canEdit ? onCellClicked : undefined} onSelectionChanged={handleSelectionChanged} - initialState={initialState} - onStateUpdated={onStateUpdated} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + onSortChanged={handleSortChanged} stopEditingWhenCellsLoseFocus />
    20} - hasPrev={false} - onNext={() => {}} - onPrev={() => {}} - onPageSizeChange={() => {}} + name="inventory" + total={totalCount} + rangeStart={pageConfig.getRangeStart(displayData.length)} + rangeEnd={Math.min( + pageConfig.getRangeEnd(displayData.length), + totalCount, + )} + pageSize={pageConfig.pageSize} + pageSizeOptions={pageConfig.pageSizeOptions} + hasNext={pageInfo?.hasNextPage ?? false} + hasPrev={pageInfo?.hasPreviousPage ?? false} + onNext={handleNextPage} + onPrev={handlePrevPage} + onPageSizeChange={pageConfig.setPageSize} disabled={!canNavigate} disabledReason="Save or discard changes to navigate" /> diff --git a/admin/src/domains/inventory/inventory/register.tsx b/admin/src/domains/inventory/inventory/register.tsx index e7298f270..2e9f52448 100644 --- a/admin/src/domains/inventory/inventory/register.tsx +++ b/admin/src/domains/inventory/inventory/register.tsx @@ -1,18 +1,43 @@ import { registerModule } from "@/registry"; import dynamic from "next/dynamic"; +import { LuLayers } from "react-icons/lu"; registerModule({ key: "stock", domain: "store", sidebar: { label: "Inventory", - icon: null, - order: 2, + icon: , + order: 5, }, items: [ { key: "stock-list", path: "/:orgName/:storeName/inventory", + sidebar: { + label: "Stock", + icon: null, + order: 1, + }, + component: dynamic( + () => import("@/domains/inventory/inventory/page/page") + ), + }, + { + key: "warehouse-list", + path: "/:orgName/:storeName/warehouses", + sidebar: { + label: "Locations", + icon: null, + order: 2, + }, + component: dynamic( + () => import("@/domains/inventory/warehouse/page/page"), + ), + }, + { + key: "stock-warehouse", + path: "/:orgName/:storeName/inventory/:warehouseId", component: dynamic( () => import("@/domains/inventory/inventory/page/page") ), diff --git a/admin/src/domains/inventory/products/components/attributes-section.tsx b/admin/src/domains/inventory/products/components/attributes-section.tsx index ffacaeac6..e53ff5894 100644 --- a/admin/src/domains/inventory/products/components/attributes-section.tsx +++ b/admin/src/domains/inventory/products/components/attributes-section.tsx @@ -1,8 +1,10 @@ import { useMemo, ReactNode } from "react"; import { createStyles } from "antd-style"; import { Descriptions, Flex, Typography } from "antd"; +import { LuTags as TagsOutlined } from "react-icons/lu"; import { Paper, PaperHeader } from "@/ui-kit/paper"; -import type { IAttributeRow } from "../modals/edit-attributes-modal/types"; +import { EntityDetailsEmptyState } from "@/domains/inventory/components/entity-details-sections"; +import type { ApiProductFeature, ApiProductFeatureValue } from "@/graphql/types"; // ============================================================================ // Styles @@ -38,25 +40,40 @@ const useStyles = createStyles(({ token }) => ({ // ============================================================================ interface IAttributesSectionProps { - data: IAttributeRow[]; + features: ApiProductFeature[]; actions?: ReactNode; } -interface IAttributeGroup { - id: string; - name: string; - attributes: IAttributeRow[]; -} - // ============================================================================ // Sub-components // ============================================================================ interface IAttributeListProps { - attributes: IAttributeRow[]; + attributes: ApiProductFeature[]; className?: string; } +function compareIndex(left: number[], right: number[]): number { + const length = Math.max(left.length, right.length); + + for (let i = 0; i < length; i += 1) { + const leftValue = left[i] ?? -1; + const rightValue = right[i] ?? -1; + + if (leftValue !== rightValue) { + return leftValue - rightValue; + } + } + + return left.length - right.length; +} + +function sortValues( + values: ApiProductFeatureValue[], +): ApiProductFeatureValue[] { + return [...values].sort((left, right) => left.index - right.index); +} + const AttributeList = ({ attributes, className }: IAttributeListProps) => { if (attributes.length === 0) return null; @@ -70,7 +87,9 @@ const AttributeList = ({ attributes, className }: IAttributeListProps) => { > {attributes.map((attr) => ( - {attr.values?.map((v) => v.name).join(", ") || "—"} + {sortValues(attr.values) + .map((value) => value.name) + .join(", ") || "--"} ))} @@ -78,22 +97,24 @@ const AttributeList = ({ attributes, className }: IAttributeListProps) => { }; interface IAttributeGroupBlockProps { - group: IAttributeGroup; + group: ApiProductFeature; + attributes: ApiProductFeature[]; className?: string; titleClassName?: string; } const AttributeGroupBlock = ({ group, + attributes, className, titleClassName, }: IAttributeGroupBlockProps) => { - if (group.attributes.length === 0) return null; + if (attributes.length === 0) return null; return (
    {group.name} - +
    ); }; @@ -102,57 +123,86 @@ const AttributeGroupBlock = ({ // Main Component // ============================================================================ -export const AttributesSection = ({ data, actions }: IAttributesSectionProps) => { +export const AttributesSection = ({ + features, + actions, +}: IAttributesSectionProps) => { const { styles } = useStyles(); - const { rootAttributes, groups } = useMemo(() => { - const rootAttrs = data - .filter((r) => r.type === "attribute" && r.parentId === null) - .sort((a, b) => a.sortIndex - b.sortIndex); - - const groupRows = data - .filter((r) => r.type === "group") - .sort((a, b) => a.sortIndex - b.sortIndex); - - const groupsWithAttrs: IAttributeGroup[] = groupRows.map((group) => ({ - id: group.id, - name: group.name, - attributes: data - .filter((r) => r.type === "attribute" && r.parentId === group.id) - .sort((a, b) => a.sortIndex - b.sortIndex), - })); + const { rootAttributes, groups, attributesByGroupId } = useMemo(() => { + const sortedFeatures = [...features].sort((left, right) => + compareIndex(left.index, right.index), + ); + const rootAttrs = sortedFeatures.filter( + (feature) => !feature.isGroup && feature.index.length === 1, + ); + const groupRows = sortedFeatures.filter( + (feature) => feature.isGroup && feature.index.length === 1, + ); + const groupedAttributes = new Map(); + + groupRows.forEach((group) => { + groupedAttributes.set( + group.id, + sortedFeatures.filter( + (feature) => + !feature.isGroup && + feature.index.length === 2 && + feature.index[0] === group.index[0], + ), + ); + }); return { rootAttributes: rootAttrs, - groups: groupsWithAttrs, + groups: groupRows, + attributesByGroupId: groupedAttributes, }; - }, [data]); + }, [features]); - const hasContent = rootAttributes.length > 0 || groups.some((g) => g.attributes.length > 0); + const hasContent = + rootAttributes.length > 0 || + groups.some( + (group) => (attributesByGroupId.get(group.id)?.length ?? 0) > 0, + ); - if (!hasContent) { + if (!hasContent && !actions) { return null; } return ( - + - {/* Root-level attributes (no group) */} - - - {/* Grouped attributes */} - {groups.map((group) => ( - - ))} + {hasContent ? ( + <> + + + {groups.map((group) => ( + + ))} + + ) : ( +
    + } + state={{ + title: "No attributes added", + description: + "Add product attributes to describe specifications, materials, and other structured details.", + }} + /> +
    + )}
    ); diff --git a/admin/src/domains/inventory/products/components/edit-action.tsx b/admin/src/domains/inventory/products/components/edit-action.tsx index a36f41476..d44bb88ff 100644 --- a/admin/src/domains/inventory/products/components/edit-action.tsx +++ b/admin/src/domains/inventory/products/components/edit-action.tsx @@ -1,19 +1,52 @@ import { Button, Dropdown } from "antd"; -import { MoreOutlined } from "@ant-design/icons"; +import type { MenuProps } from "antd"; +import { LuEllipsis as MoreOutlined } from "react-icons/lu"; interface IEditActionProps { - onEdit: () => void; + onEdit: () => void | Promise; label?: string; + testId?: string; + loading?: boolean; + disabled?: boolean; + items?: MenuProps["items"]; } -export const EditAction = ({ onEdit, label = "Edit" }: IEditActionProps) => ( - onEdit(), - }} - trigger={["click"]} - > - - - ), - }, - { - key: "excerpt", - label: "Excerpt", - children: excerptHtml ? ( -
    - ) : ( - - - - No excerpt added - - - - ), - }, - ]} - /> - + + + Edit content, + onClick: handleEditDescription, + }, + ], + }} + trigger={["click"]} + > + + ) : undefined + } + /> {hasCategories ? ( {primaryCategory && ( @@ -101,6 +249,7 @@ export const CategoriesSection = ({ key: "delete", label: "Delete category", onClick: () => deleteCategory(primaryCategory.id), + disabled: isPending, }, ], }} @@ -108,7 +257,7 @@ export const CategoriesSection = ({ - {primaryCategory.title} + {primaryCategory.name} @@ -124,18 +273,20 @@ export const CategoriesSection = ({ key: "set-as-primary", label: "Set as primary", onClick: () => setPrimary(category.id), + disabled: isPending, }, { key: "delete", label: "Delete category", onClick: () => deleteCategory(category.id), + disabled: isPending, }, ], }} > - {category.title} + {category.name} @@ -143,9 +294,9 @@ export const CategoriesSection = ({ ))} ) : ( - - - - - Add Category - - - - No categories assigned - - + } + state={{ + title: "No categories added", + description: + "Add categories to organize this product in storefront navigation and collections.", + }} + /> )} ); diff --git a/admin/src/domains/inventory/products/components/product-details-card/sections/index.ts b/admin/src/domains/inventory/products/components/product-details-card/sections/index.ts index f957a4ce9..514b9b381 100644 --- a/admin/src/domains/inventory/products/components/product-details-card/sections/index.ts +++ b/admin/src/domains/inventory/products/components/product-details-card/sections/index.ts @@ -3,7 +3,6 @@ export { CategoriesSection } from "./categories-section"; export { TagsSection } from "./tags-section"; export { ReviewsSection } from "./reviews-section"; export { OptionsSection } from "./options-section"; -export { ShippingSection } from "./shipping-section"; export { BundlesSection } from "./bundles-section"; export { InventorySection } from "./inventory-section"; export { VariantsTableSection } from "./variants-table-section"; diff --git a/admin/src/domains/inventory/products/components/product-details-card/sections/inventory-section.tsx b/admin/src/domains/inventory/products/components/product-details-card/sections/inventory-section.tsx index 92af6fde2..7d10123ac 100644 --- a/admin/src/domains/inventory/products/components/product-details-card/sections/inventory-section.tsx +++ b/admin/src/domains/inventory/products/components/product-details-card/sections/inventory-section.tsx @@ -4,63 +4,22 @@ import { Typography, Button, Tag, - Dropdown, Skeleton, Flex, } from "antd"; -import { - MoreOutlined, - ClockCircleFilled, - WarningOutlined, - StopOutlined, -} from "@ant-design/icons"; -import { useState, useCallback } from "react"; +import { LuClock as ClockCircleFilled, LuTriangleAlert as WarningOutlined, LuBan as StopOutlined } from "react-icons/lu"; +import { useState } from "react"; import { Paper, PaperHeader } from "@/ui-kit/paper"; import { KPITile } from "@/ui-kit/kpi-tile"; import { useInventoryStyles } from "../product-details-card.styles"; -import { useEditVariantsModal } from "../../../modals"; -import type { ProductInventoryWidget } from "../inventory-widget.types"; -import { ThresholdType } from "../inventory-widget.types"; -import type { IProduct } from "@/mocks/products/types"; - +import { ThresholdMethod } from "@/graphql/types"; +import type { ApiProduct } from "@/graphql/types"; +import { useProductInventoryWidget } from "../../../hooks/use-product-inventory-widget"; // ============================================================================ // Sub-components // ============================================================================ -interface IInventoryActionsProps { - onAction: (action: string) => void; -} - -const InventoryActions = ({ onAction }: IInventoryActionsProps) => { - const items = [{ key: "edit", label: "Edit inventory" }]; - - return ( - onAction(key), - }} - trigger={["click"]} - > -
    @@ -284,15 +214,18 @@ export const InventorySection = ({ 0 ? "warning" : "default"} badge={ @@ -305,15 +238,19 @@ export const InventorySection = ({ } active={activeKPI === "lowstock"} onClick={() => handleKPIClick("lowstock")} + dataTestId="inventory-widget-kpi-low-stock" /> 0 ? "danger" : "default"} badge={ @@ -326,6 +263,7 @@ export const InventorySection = ({ } active={activeKPI === "outofstock"} onClick={() => handleKPIClick("outofstock")} + dataTestId="inventory-widget-kpi-out-of-stock" /> {stats.skuStatus.backorder.count > 0 && ( handleKPIClick("backorder")} + dataTestId="inventory-widget-kpi-backorder" /> )}
    diff --git a/admin/src/domains/inventory/products/components/product-details-card/sections/media-section.tsx b/admin/src/domains/inventory/products/components/product-details-card/sections/media-section.tsx index a7b5c189b..1503d5129 100644 --- a/admin/src/domains/inventory/products/components/product-details-card/sections/media-section.tsx +++ b/admin/src/domains/inventory/products/components/product-details-card/sections/media-section.tsx @@ -1,111 +1,157 @@ "use client"; +import type { ReactNode } from "react"; import { Image, Flex } from "antd"; -import { EyeOutlined, PlusOutlined } from "@ant-design/icons"; +import { LuEye as EyeOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; import { Paper, PaperHeader } from "@/ui-kit/paper"; import { FeaturedBadge } from "@/ui-kit/featured-badge"; import { MediaPreview, useMediaPreview } from "@/domains/media/components/media-preview"; +import { EntityMediaEmptyState } from "@/domains/inventory/components/entity-details-sections"; import { EditAction } from "../../edit-action"; import { MediaFilePlaceholder } from "../../media-file-placeholder"; import { useMediaStyles } from "../product-details-card.styles"; import type { ApiFile } from "@/graphql/types"; interface IMediaSectionProps { - gallery: ApiFile[]; + mediaFiles: ApiFile[]; onEdit: () => void; + title?: string; + editLabel?: string; + hasFeatured?: boolean; + testIdPrefix?: string; + renderItemBadge?: (file: ApiFile, index: number) => ReactNode; + onOpenItem?: (file: ApiFile, index: number) => void; + footer?: ReactNode; + emptyState?: ReactNode; } -export const MediaSection = ({ gallery, onEdit }: IMediaSectionProps) => { +export const MediaSection = ({ + mediaFiles, + onEdit, + title = "Media", + editLabel = "Edit media", + hasFeatured = true, + testIdPrefix = "product-media", + renderItemBadge, + onOpenItem, + footer, + emptyState, +}: IMediaSectionProps) => { const { styles } = useMediaStyles(); - const mediaPreview = useMediaPreview(gallery); + const mediaPreview = useMediaPreview(mediaFiles); - const showMore = gallery.length > 12; - const gallerySlice = gallery.slice(0, showMore ? 11 : 12); - const overlayItemsCount = gallerySlice.length + (showMore ? 1 : 0) + 1; // +1 for upload cell + const showMore = mediaFiles.length > 12; + const visibleMediaFiles = mediaFiles.slice(0, showMore ? 11 : 12); + const overlayItemsCount = visibleMediaFiles.length + (showMore ? 1 : 0) + 1; // +1 for upload cell + const hasMedia = mediaFiles.length > 0; return ( } + title={title} + actions={ + + } /> -
    - {gallerySlice.map((media, index) => - index === 0 ? ( -
    - {media.altText - - Preview - - ), - }} - onClick={() => mediaPreview.open(index)} - /> - -
    - ) : ( - {media.altText - - Preview - - ), - }} - onClick={() => mediaPreview.open(index)} - /> - ) - )} - {showMore && ( - mediaPreview.open(11)} - > - +{gallery.length - 11} - - )} -
    -
    { - if (e.key === "Enter" || e.key === " ") { - onEdit(); + {hasMedia ? ( +
    + {visibleMediaFiles.map((media, index) => { + const open = () => { + if (onOpenItem) { + onOpenItem(media, index); + return; } - }} - > - + mediaPreview.open(index); + }; + return ( +
    + {media.mimeType?.startsWith("video/") ? ( +
    + ); + })} + {showMore && ( + mediaPreview.open(11)} + > + +{mediaFiles.length - 11} + + )} +
    +
    { + if (e.key === "Enter" || e.key === " ") { + onEdit(); + } + }} + > + +
    +
    +
    + {Array.from({ length: overlayItemsCount }).map((_, idx) => ( +
    + ))} + {Array.from({ length: Math.max(0, 13 - overlayItemsCount) }).map((_, idx) => ( + + ))}
    -
    - {Array.from({ length: overlayItemsCount }).map((_, idx) => ( -
    - ))} - {Array.from({ length: Math.max(0, 13 - overlayItemsCount) }).map((_, idx) => ( - - ))} + ) : ( +
    + {emptyState ?? }
    -
    + )} + + {footer} void; + actions?: ReactNode; } +const DISPLAY_TYPE_METADATA: Record< + OptionDisplayType, + { label: string; icon: ReactNode } +> = { + [OptionDisplayType.Swatch]: { + label: "Swatch", + icon: , + }, + [OptionDisplayType.Buttons]: { + label: "Buttons", + icon: , + }, + [OptionDisplayType.Dropdown]: { + label: "Dropdown", + icon: , + }, +}; + const SwatchPreview = ({ swatch, }: { @@ -61,54 +79,67 @@ const SwatchPreview = ({ return null; }; -export const OptionsSection = ({ options, onEdit }: IOptionsSectionProps) => { +export const OptionsSection = ({ options, actions }: IOptionsSectionProps) => { const { styles } = useOptionsStyles(); - if (!options || options.length === 0) { + if ((!options || options.length === 0) && !actions) { return null; } return ( - - } - /> + + - {options.map((option) => { - const displayTypeOption = DISPLAY_TYPE_OPTIONS.find((s) => s.key === option.displayType); - const showSwatch = option.displayType === OptionDisplayType.Swatch; + {options.length > 0 ? ( + options.map((option) => { + const displayTypeOption = DISPLAY_TYPE_METADATA[option.displayType]; + const showSwatch = option.displayType === OptionDisplayType.Swatch; - return ( -
    - - {displayTypeOption && displayTypeOption.icon} - - {option.name} - - - - {option.values?.map((value) => ( - - ) : null - } - > - {value.name} - - ))} - -
    - ); - })} + return ( +
    + + {displayTypeOption.icon} + + {option.name} + + + {displayTypeOption.label} + + + + {option.values?.map((value) => ( + + ) : null + } + > + {value.name} + + ))} + +
    + ); + }) + ) : ( +
    + } + state={{ + title: "No options added", + description: + "Add product options to define selectable values like size, color, or material.", + }} + /> +
    + )}
    ); diff --git a/admin/src/domains/inventory/products/components/product-details-card/sections/reviews-section.tsx b/admin/src/domains/inventory/products/components/product-details-card/sections/reviews-section.tsx index ae870cd50..515bfcb80 100644 --- a/admin/src/domains/inventory/products/components/product-details-card/sections/reviews-section.tsx +++ b/admin/src/domains/inventory/products/components/product-details-card/sections/reviews-section.tsx @@ -1,86 +1,104 @@ "use client"; -import { Typography, Rate, Progress, Flex } from "antd"; -import { StarFilled } from "@ant-design/icons"; +import { Flex, Progress, Rate, Skeleton, Typography } from "antd"; +import { LuChartBar as BarChartOutlined, LuPencil as EditOutlined, LuStar as StarFilled } from "react-icons/lu"; +import type { ApiProductReviewSummary } from "@/graphql/types"; import { Paper, PaperHeader } from "@/ui-kit/paper"; import { EditAction } from "../../edit-action"; import { useReviewsStyles } from "../product-details-card.styles"; -import type { IReviewBreakdown } from "../types"; -interface IReviewsSectionProps { - rating: number; - reviewsCount: number; - breakdown: IReviewBreakdown[]; - onEdit: () => void; +interface ReviewsSectionProps { + summary: ApiProductReviewSummary | null; + loading?: boolean; + error?: Error | null; + onEdit?: () => void; + onViewInsights?: () => void; } export const ReviewsSection = ({ - rating, - reviewsCount, - breakdown, + summary, + loading = false, + error = null, onEdit, -}: IReviewsSectionProps) => { + onViewInsights, +}: ReviewsSectionProps) => { const { styles } = useReviewsStyles(); + const reviewCount = summary?.reviewCount ?? 0; + const rating = summary?.averageRating ?? 0; + const breakdown = [ + { stars: 5, count: summary?.ratingBreakdown.rating5Count ?? 0 }, + { stars: 4, count: summary?.ratingBreakdown.rating4Count ?? 0 }, + { stars: 3, count: summary?.ratingBreakdown.rating3Count ?? 0 }, + { stars: 2, count: summary?.ratingBreakdown.rating2Count ?? 0 }, + { stars: 1, count: summary?.ratingBreakdown.rating1Count ?? 0 }, + ]; return ( } + actions={onEdit || onViewInsights ? undefined)} items={[ + ...(onViewInsights ? [{ key: "insights", label: "Product insights", icon: , onClick: onViewInsights }] : []), + ...(onEdit ? [{ key: "edit", label: "Edit reviews", icon: , onClick: onEdit }] : []), + ]} /> : undefined} /> -
    - {/* Left side - Average rating */} - - - {rating.toFixed(1)} - - - - {reviewsCount} reviews - - + {loading && !summary ? ( + + ) : error && !summary ? ( + {error.message} + ) : ( +
    + + + {rating.toFixed(1)} + + + + {reviewCount} reviews + + - {/* Right side - Rating breakdown */} - - {breakdown.map((item) => ( - - - {item.stars} - - - - + {breakdown.map((item) => ( + - {item.count} - - - ))} - -
    + + {item.stars} + + + 0 ? (item.count / reviewCount) * 100 : 0} + showInfo={false} + strokeWidth={4} + railColor="var(--ant-color-fill-tertiary)" + size="small" + className={styles.reviewProgress} + /> + + {item.count} + + + ))} + +
    + )}
    ); }; diff --git a/admin/src/domains/inventory/products/components/product-details-card/sections/shipping-section.tsx b/admin/src/domains/inventory/products/components/product-details-card/sections/shipping-section.tsx deleted file mode 100644 index 856d6594f..000000000 --- a/admin/src/domains/inventory/products/components/product-details-card/sections/shipping-section.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Tag, Flex } from "antd"; -import { Paper, PaperHeader } from "@/ui-kit/paper"; -import { EditAction } from "../../edit-action"; -import { KPITile } from "@/ui-kit/kpi-tile"; -import { weightUnitOptions, dimensionUnitOptions } from "../../../constants"; - -interface IShippingSectionProps { - weight: number | null; - weightUnit: string; - length: number | null; - width: number | null; - height: number | null; - dimensionUnit: string; - requiresShipping: boolean; - onEdit: () => void; -} - -export const ShippingSection = ({ - weight, - weightUnit, - length, - width, - height, - dimensionUnit, - requiresShipping, - onEdit, -}: IShippingSectionProps) => { - const formatWeight = (w: number | null, unit: string) => { - if (!w) return "\u2014"; - return `${w} ${ - weightUnitOptions[unit as keyof typeof weightUnitOptions]?.label || unit - }`; - }; - - const formatDimensions = ( - l: number | null, - w: number | null, - h: number | null, - unit: string - ) => { - if (!l && !w && !h) return "\u2014"; - const u = - dimensionUnitOptions[unit as keyof typeof dimensionUnitOptions]?.label || - unit; - return `${l || 0} \u00d7 ${w || 0} \u00d7 ${h || 0} ${u}`; - }; - - return ( - - } - /> - - - - - {requiresShipping ? "Active" : "Disabled"} - - } - centered - /> - - - ); -}; diff --git a/admin/src/domains/inventory/products/components/product-details-card/sections/tags-section.tsx b/admin/src/domains/inventory/products/components/product-details-card/sections/tags-section.tsx index a1bfa1def..1a37f2413 100644 --- a/admin/src/domains/inventory/products/components/product-details-card/sections/tags-section.tsx +++ b/admin/src/domains/inventory/products/components/product-details-card/sections/tags-section.tsx @@ -1,109 +1,151 @@ "use client"; -import { useState } from "react"; -import { Tag, Typography, Flex, Dropdown } from "antd"; -import { PlusOutlined, MoreOutlined } from "@ant-design/icons"; -import { Paper, PaperHeader } from "@/ui-kit/paper"; -import { useTagPicker } from "@/shared/components/entity-picker-modal"; -import type { IPickableEntity } from "@/shared/components/entity-picker-modal"; -import type { ITag } from "../../../modals"; +import { useEffect, useState } from "react"; +import { App } from "antd"; +import type { IPickableEntity } from "@/shared/components/entity-picker-modal/types"; +import { + EntityTagsSection, + type EntityDetailsTagItem, +} from "@/domains/inventory/components/entity-details-sections"; +import type { ApiTag, ApiProductUpdateInput } from "@/graphql/types"; +import { ProductTagOperationAction } from "@/graphql/types"; +import { useUpdateProduct } from "@/domains/inventory/products/hooks"; interface ITagsSectionProps { - tags?: ITag[]; + productId?: string; + productRevision?: number | null; + tags?: ApiTag[]; + onProductRefresh?: () => Promise; } -export const TagsSection = ({ tags: initialTags = [] }: ITagsSectionProps) => { - const [tags, setTags] = useState(initialTags); +const toTagItem = (tag: ApiTag): EntityDetailsTagItem => ({ + id: tag.id, + name: tag.name, + handle: tag.handle, +}); - const deleteTag = (id: string) => { - setTags((prev) => prev.filter((tag) => tag.id !== id)); +export const TagsSection = ({ + productId, + productRevision, + tags: initialTags = [], + onProductRefresh, +}: ITagsSectionProps) => { + const { message } = App.useApp(); + const { updateProduct } = useUpdateProduct(); + const [pendingTagId, setPendingTagId] = useState(null); + const [tags, setTags] = useState(() => + initialTags.map(toTagItem), + ); + const initialTagsKey = initialTags.map((tag) => tag.id).join("|"); + + useEffect(() => { + setTags(initialTags.map(toTagItem)); + }, [initialTagsKey]); + + const refreshProduct = async () => { + try { + await onProductRefresh?.(); + } catch { + message.warning("Tag changes saved, but product refresh failed"); + } + }; + + const saveTagOperations = async (operations: ApiProductUpdateInput["tags"]) => { + if (!productId) { + message.error("Product id is missing"); + return false; + } + + const result = await updateProduct({ + productId, + expectedRevision: productRevision, + operations: { + tags: operations, + }, + }); + + if (result.errors.length > 0) { + message.error(result.errors[0].message); + return false; + } + + return true; + }; + + const deleteTag = async (id: string) => { + setPendingTagId(id); + try { + const saved = await saveTagOperations([ + { + tagId: id, + action: ProductTagOperationAction.Remove, + }, + ]); + + if (!saved) { + return; + } + + setTags((prev) => prev.filter((tag) => tag.id !== id)); + await refreshProduct(); + message.success("Tag removed from product"); + } finally { + setPendingTagId(null); + } + }; + + const addTags = async (entities: IPickableEntity[]) => { + const existingById = new Map(tags.map((tag) => [tag.id, tag])); + const newTags = entities + .filter((entity) => !existingById.has(entity.id)) + .map((entity): EntityDetailsTagItem => ({ + id: entity.id, + name: entity.title, + handle: + "handle" in entity && typeof entity.handle === "string" + ? entity.handle + : null, + })); + + if (newTags.length === 0) { + return; + } + + setPendingTagId(newTags[0].id); + try { + const saved = await saveTagOperations( + newTags.map((tag) => ({ + tagId: tag.id, + action: ProductTagOperationAction.Add, + })), + ); + + if (!saved) { + return; + } + + setTags((prev) => [...prev, ...newTags]); + await refreshProduct(); + message.success( + newTags.length === 1 + ? "Tag added to product" + : "Tags added to product", + ); + } finally { + setPendingTagId(null); + } }; - const { openPicker } = useTagPicker({ - initialSelection: tags.map((tag) => tag.id), - onConfirm: (entities: IPickableEntity[]) => { - const existingById = new Map(tags.map((t) => [t.id, t])); - const newTags = entities.map((entity) => { - const existing = existingById.get(entity.id); - if (existing) { - return existing; - } - return { - id: entity.id, - title: entity.title, - slug: entity.id, - color: "var(--ant-color-primary)", - } satisfies ITag; - }); - setTags(newTags); - }, - }); - - const hasTags = tags.length > 0; + const isPending = pendingTagId !== null; return ( - - - {hasTags ? ( - - {tags.map((tag) => ( - deleteTag(tag.id), - }, - ], - }} - > - - - {tag.title} - - - - - ))} - - - - Add Tag - - - - ) : ( - - - - - Add Tag - - - - No tags assigned - - - )} - + ); }; diff --git a/admin/src/domains/inventory/products/components/product-details-card/sections/variants-table-section.tsx b/admin/src/domains/inventory/products/components/product-details-card/sections/variants-table-section.tsx index 3514a7a82..0d7c8a03f 100644 --- a/admin/src/domains/inventory/products/components/product-details-card/sections/variants-table-section.tsx +++ b/admin/src/domains/inventory/products/components/product-details-card/sections/variants-table-section.tsx @@ -1,16 +1,22 @@ "use client"; -import { Typography, Button, Image, Dropdown, Flex } from "antd"; -import { - MoreOutlined, - SortAscendingOutlined, - LeftOutlined, - RightOutlined, -} from "@ant-design/icons"; +import { Typography, Button, Dropdown, Flex } from "antd"; +import { LuEllipsis as MoreOutlined, LuImage as PictureOutlined, LuArrowDownAZ as SortAscendingOutlined, LuChevronLeft as LeftOutlined, LuChevronRight as RightOutlined } from "react-icons/lu"; import { Paper, PaperHeader } from "@/ui-kit/paper"; import { EditAction } from "../../edit-action"; import { useVariantsTableStyles } from "../product-details-card.styles"; -import type { ApiVariant, ApiPageInfo } from "../types"; +import type { + ApiVariant, + ApiPageInfo, + ApiProductOption, + CurrencyCode, +} from "@/graphql/types"; +import { formatPrice } from "../../../utils/price-formatting"; +import { + getSelectedOptionLabels, + getVariantStockQuantity, +} from "../../../utils/api-product-display"; +import { TableCoverImage } from "@/shared/components/table-cover-image"; // ============================================================================ // Stock Status Config @@ -47,52 +53,40 @@ const stockStatusConfig: Record< // ============================================================================ const getStockStatus = (variant: ApiVariant): string => { - const totalStock = variant.stock.reduce( - (acc, s) => acc + s.quantityOnHand, - 0 - ); + const totalStock = getVariantStockQuantity(variant); if (totalStock === 0) return "OUT_OF_STOCK"; if (totalStock < 10) return "LOW_STOCK"; return "IN_STOCK"; }; -const formatWeight = (weight: ApiVariant["weight"]): string => { - if (!weight) return "\u2014"; - // Weight is in grams - if (weight.value >= 1000) { - return `${(weight.value / 1000).toFixed(1)} kg`; - } - return `${weight.value} g`; -}; - -const formatDimensions = (dimensions: ApiVariant["dimensions"]): string => { - if (!dimensions) return "\u2014"; - // Dimensions are in mm - const { length, width, height } = dimensions; - if (length >= 10 || width >= 10 || height >= 10) { - return `${(length / 10).toFixed(0)} \u00d7 ${(width / 10).toFixed(0)} \u00d7 ${(height / 10).toFixed(0)} cm`; - } - return `${length} \u00d7 ${width} \u00d7 ${height} mm`; -}; - // ============================================================================ // Sub-components // ============================================================================ interface IVariantRowProps { variant: ApiVariant; - formatPrice: (price: number) => string; + productOptions: ApiProductOption[]; + defaultCurrency: CurrencyCode | null; onAction: (action: string, variantId: string) => void; } -const VariantRow = ({ variant, formatPrice, onAction }: IVariantRowProps) => { +const VariantRow = ({ + variant, + productOptions, + defaultCurrency, + onAction, +}: IVariantRowProps) => { const { styles } = useVariantsTableStyles(); - const price = variant.price?.amountMinor ?? 0; + const price = variant.price?.amountMinor ?? null; const compareAtPrice = variant.price?.compareAtMinor ?? null; + const formattedPrice = + price !== null && defaultCurrency + ? formatPrice(price, defaultCurrency) + : "\u2014"; const discountPercent = - compareAtPrice && compareAtPrice > price + price !== null && compareAtPrice !== null && compareAtPrice > price ? Math.round((1 - Number(price) / Number(compareAtPrice)) * 100) : 0; @@ -103,37 +97,33 @@ const VariantRow = ({ variant, formatPrice, onAction }: IVariantRowProps) => { label: "N/A", }; - const imageUrl = variant.media?.[0]?.file?.url; + const imageUrl = [...variant.media].sort( + (left, right) => left.sortIndex - right.sortIndex, + )[0]?.file.url; + const inventoryItem = variant.inventoryItem; + const optionLabels = getSelectedOptionLabels(productOptions, variant); return ( - + {/* VARIANT */} - + - {imageUrl ? ( - - ) : ( -
    - )} + } + className={styles.variantImage} + /> - {variant.title || variant.sku || "\u2014"} + {variant.title ?? variant.handle} - {variant.selectedOptions && variant.selectedOptions.length > 0 && ( + {optionLabels.length > 0 && ( - {variant.selectedOptions - .map((o) => `${o.optionId}:${o.optionValueId}`) - .join(" / ")} + {optionLabels.join(" / ")} )} @@ -141,18 +131,22 @@ const VariantRow = ({ variant, formatPrice, onAction }: IVariantRowProps) => { {/* PRICING */} - + - {formatPrice(Number(price))} - {compareAtPrice && + + {formattedPrice} + + {compareAtPrice !== null && compareAtPrice > 0 && + price !== null && + defaultCurrency && compareAtPrice !== price && ( - {formatPrice(Number(compareAtPrice))} + {formatPrice(compareAtPrice, defaultCurrency)} {discountPercent > 0 && ( @@ -165,10 +159,10 @@ const VariantRow = ({ variant, formatPrice, onAction }: IVariantRowProps) => { {/* INVENTORY */} - + - {variant.sku || "\u2014"} + {inventoryItem?.sku ?? "\u2014"} { - {/* ATTRIBUTES */} - - - - {formatWeight(variant.weight)} - - - {formatDimensions(variant.dimensions)} - - - - {/* ACTIONS */} string; - onEdit: () => void; + defaultCurrency: CurrencyCode | null; + onEdit: () => void | Promise; onSort?: (sortKey: string) => void; onVariantAction?: (action: string, variantId: string) => void; onPageChange?: (direction: "next" | "prev") => void; + isPageLoading?: boolean; + isEditLoading?: boolean; } export const VariantsTableSection = ({ variants, + productOptions, pageInfo, totalCount, - formatPrice, + defaultCurrency, onEdit, onSort, onVariantAction, onPageChange, + isPageLoading = false, + isEditLoading = false, }: IVariantsTableSectionProps) => { const { styles } = useVariantsTableStyles(); - if (!variants || variants.length === 0) { - return null; - } - const handleSort = (key: string) => { onSort?.(key); }; @@ -278,7 +262,7 @@ export const VariantsTableSection = ({ }; return ( - + } /> - + } />
    - +
    - - {variants.map((variant) => ( - - ))} + {variants.length > 0 ? ( + variants.map((variant) => ( + + )) + ) : ( + + + + )}
    Variant Pricing InventoryAttributes
    + + + No variants on this page + + +
    @@ -333,22 +335,28 @@ export const VariantsTableSection = ({ {totalCount} variant{totalCount !== 1 ? "s" : ""} + {isPageLoading ? " - Loading" : ""}
    + ); +}; + // ============================================================================ // Option Cell (read-only, displays option value) // ============================================================================ @@ -203,7 +260,11 @@ export const OptionCellRenderer: React.FC< if (!data) return null; const option = data.options.find((o) => o.name === optionName); - const value = option?.value ?? ""; + const value = option?.value; - return {value}; + return ( + + {isEmptyCellValue(value) ? : value} + + ); }; diff --git a/admin/src/domains/inventory/products/components/variants/components/index.ts b/admin/src/domains/inventory/products/components/variants/components/index.ts index 5a42ca18a..bd6b025ef 100644 --- a/admin/src/domains/inventory/products/components/variants/components/index.ts +++ b/admin/src/domains/inventory/products/components/variants/components/index.ts @@ -2,7 +2,6 @@ export { VariantsColumnSettings } from "./variants-column-settings"; export { VariantsEditorGrid, extractOptionGroups, - type IVariantInput, } from "./variants-editor-grid"; export { ImageCellRenderer, @@ -11,7 +10,5 @@ export { NumberCellRenderer, PriceCellRenderer, OptionCellRenderer, - ReservedCellRenderer, - AvailableCellRenderer, formatPrice, } from "./cell-renderers"; diff --git a/admin/src/domains/inventory/products/components/variants/components/variants-column-settings.tsx b/admin/src/domains/inventory/products/components/variants/components/variants-column-settings.tsx index ef4dc070a..c2b796cdb 100644 --- a/admin/src/domains/inventory/products/components/variants/components/variants-column-settings.tsx +++ b/admin/src/domains/inventory/products/components/variants/components/variants-column-settings.tsx @@ -1,15 +1,14 @@ import React from "react"; import { createStyles } from "antd-style"; import { Popover, Checkbox, Divider, Typography, Button } from "antd"; -import { SettingOutlined } from "@ant-design/icons"; +import { LuSettings as SettingOutlined } from "react-icons/lu"; import { useVariantsEditorStore } from "../hooks"; import { MEDIA_COLUMNS, PRICING_COLUMNS, - INVENTORY_COLUMNS, ATTRIBUTES_COLUMNS, - type IOptionGroup, } from "../config"; +import type { IOptionGroup } from "../config/types"; const { Text } = Typography; @@ -72,7 +71,6 @@ export const VariantsColumnSettings: React.FC = ({ const sections = [ { title: "Options", columns: optionGroups, isOptions: true }, { title: "Pricing", columns: PRICING_COLUMNS, isOptions: false }, - { title: "Inventory", columns: INVENTORY_COLUMNS, isOptions: false }, { title: "Attributes", columns: ATTRIBUTES_COLUMNS, isOptions: false }, ]; @@ -145,7 +143,7 @@ export const VariantsColumnSettings: React.FC = ({ trigger="click" placement="bottomLeft" > - diff --git a/admin/src/domains/inventory/products/components/variants/components/variants-editor-grid.tsx b/admin/src/domains/inventory/products/components/variants/components/variants-editor-grid.tsx index 28e8756a1..41e529023 100644 --- a/admin/src/domains/inventory/products/components/variants/components/variants-editor-grid.tsx +++ b/admin/src/domains/inventory/products/components/variants/components/variants-editor-grid.tsx @@ -1,49 +1,33 @@ "use client"; -import React, { useCallback, useMemo, useState, useEffect } from "react"; -import { App } from "antd"; +import React, { useCallback, useMemo, useEffect } from "react"; import { EditorGrid } from "@/shared/components/editor-grid"; -import { validateFieldChange } from "@/shared/utils/inventory"; +import type { ICellSelection } from "@/shared/components/ag-grid-cell-selection"; import { useVariantsEditorStore, useVariantsColumns } from "../hooks"; import { SELECTABLE_COLUMNS, - type IVariantEditorRow, - type IOptionGroup, - type VariantColumnField, } from "../config"; +import { mapVariantEditorInputsToRows } from "../../../mappers/product-variant-editor.mapper"; +import type { + IVariantEditorInput, + IVariantEditorRow, + IOptionGroup, + VariantColumnField, +} from "../config/types"; +import type { ApiFile, ApiProductOption, CurrencyCode } from "@/graphql/types"; +import { + useEditMediaModal, + type IEditMediaModalPayload, +} from "../../../modals"; // ============================================================================ // Types // ============================================================================ -export interface IVariantInput { - id: string; - title: string; - imageUrl?: string | null; - media?: string[] | null; - options?: Array<{ name: string; value: string }>; - // Inventory identification - sku?: string | null; - barcode?: string | null; - // Inventory quantities (same model as inventory table) - onHand?: number; - unavailable?: number; - reserved?: number; - // Pricing - price?: number; - compareAtPrice?: number | null; - costPrice?: number | null; - // Shipping - weight?: number | null; - weightUnit?: string; - length?: number | null; - width?: number | null; - height?: number | null; - dimensionUnit?: string; -} +export type IVariantInput = IVariantEditorInput; interface VariantsEditorGridProps { - variants: IVariantInput[]; + variants: IVariantEditorInput[]; onChange?: (rows: IVariantEditorRow[]) => void; /** * When provided, only these columns will be shown. @@ -55,13 +39,40 @@ interface VariantsEditorGridProps { * Useful for restricted views like pricing modal. */ ignoreUserSettings?: boolean; + /** + * When provided, only these visible columns can be edited. + * If undefined, column config decides editability. + */ + editableColumns?: VariantColumnField[]; + defaultCurrency?: CurrencyCode | null; + productOptions?: ApiProductOption[]; + productMediaFiles?: ApiFile[]; + allowDraftRows?: boolean; + allowDeleteRows?: boolean; + dataTestId?: string; } // ============================================================================ // Helpers // ============================================================================ -function extractOptionGroups(variants: IVariantInput[]): IOptionGroup[] { +function extractOptionGroups( + variants: IVariantEditorInput[], + productOptions: ApiProductOption[] = [], +): IOptionGroup[] { + const apiOptionGroups = [...productOptions] + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((option) => ({ + name: option.name, + values: [...option.values] + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((value) => value.name), + })); + + if (apiOptionGroups.length > 0) { + return apiOptionGroups; + } + const groupMap = new Map>(); for (const variant of variants) { @@ -79,42 +90,6 @@ function extractOptionGroups(variants: IVariantInput[]): IOptionGroup[] { })); } -function variantsToRows(variants: IVariantInput[]): IVariantEditorRow[] { - return variants.map((v) => { - const onHand = v.onHand ?? 0; - const unavailable = v.unavailable ?? 0; - const reserved = v.reserved ?? 0; - const available = onHand - unavailable - reserved; - - return { - id: v.id, - title: v.title, - imageUrl: v.imageUrl ?? null, - media: v.media ?? null, - options: v.options || [], - // Inventory identification - sku: v.sku ?? null, - barcode: v.barcode ?? null, - // Inventory quantities - onHand, - unavailable, - reserved, - available, - // Pricing - price: v.price ?? 0, - compareAtPrice: v.compareAtPrice ?? null, - costPrice: v.costPrice ?? null, - // Shipping - weight: v.weight ?? null, - weightUnit: v.weightUnit ?? "G", - length: v.length ?? null, - width: v.width ?? null, - height: v.height ?? null, - dimensionUnit: v.dimensionUnit ?? "CM", - }; - }); -} - // ============================================================================ // Component // ============================================================================ @@ -124,86 +99,264 @@ export const VariantsEditorGrid: React.FC = ({ onChange, availableColumns, ignoreUserSettings = false, + editableColumns, + defaultCurrency, + productOptions = [], + productMediaFiles = [], + allowDraftRows = true, + allowDeleteRows = true, + dataTestId = "variants-editor-grid", }) => { // Extract option groups for column generation const optionGroups = useMemo( - () => extractOptionGroups(variants), - [variants] + () => extractOptionGroups(variants, productOptions), + [variants, productOptions] ); - const { message } = App.useApp(); + const { push: openEditMediaModal } = useEditMediaModal(); // Transform variants to row data const initialRows = useMemo( - () => variantsToRows(variants), + () => mapVariantEditorInputsToRows(variants), [variants] ); + const currency = defaultCurrency ?? null; + // Store hooks const edits = useVariantsEditorStore((s) => s.edits); + const draftRows = useVariantsEditorStore((s) => s.draftRows); + const materializedRows = useVariantsEditorStore((s) => s.materializedRows); + const deletedExistingRows = useVariantsEditorStore( + (s) => s.deletedExistingRows, + ); + const committedDeletedRowIds = useVariantsEditorStore( + (s) => s.committedDeletedRowIds, + ); + const blankRow = useVariantsEditorStore((s) => s.blankRow); + const rowErrors = useVariantsEditorStore((s) => s.rowErrors); const setFieldValue = useVariantsEditorStore((s) => s.setFieldValue); + const deleteVariantRow = useVariantsEditorStore((s) => s.deleteVariantRow); + const getCurrentRows = useVariantsEditorStore((s) => s.getCurrentRows); - // Columns - pass availableColumns and ignoreUserSettings - const columns = useVariantsColumns({ - optionGroups, - availableColumns, - ignoreUserSettings, - }); + const rows = useMemo(() => { + const sessionRows = allowDraftRows && blankRow + ? [...materializedRows, ...draftRows, blankRow] + : [...materializedRows, ...draftRows]; + + return [...initialRows, ...sessionRows].map((row) => ({ + ...row, + rowError: rowErrors[row.id] ?? null, + })); + }, [ + allowDraftRows, + blankRow, + draftRows, + initialRows, + materializedRows, + rowErrors, + ]); + const selectableColumns = useMemo(() => { + if (!editableColumns) { + return SELECTABLE_COLUMNS; + } - // Local row state (original data) - const [rows] = useState(initialRows); + const editableColumnSet = new Set(editableColumns); + + return SELECTABLE_COLUMNS.filter((field) => editableColumnSet.has(field)); + }, [editableColumns]); + const isFieldEditable = useCallback( + (field: string) => { + if (!editableColumns) { + return SELECTABLE_COLUMNS.includes(field); + } + + return editableColumns.includes(field as VariantColumnField); + }, + [editableColumns], + ); // Compute display rows (with edits applied) const displayRows = useMemo(() => { - return rows.map((row) => { - const rowEdits = edits[row.id]; - if (!rowEdits) return row; + const currentRows = getCurrentRows(initialRows); + + return allowDraftRows + ? currentRows + : currentRows.filter((row) => row.kind !== "blank"); + }, [ + allowDraftRows, + blankRow, + draftRows, + edits, + deletedExistingRows, + committedDeletedRowIds, + getCurrentRows, + initialRows, + materializedRows, + rowErrors, + ]); - const updatedRow = { ...row }; - for (const [field, edit] of Object.entries(rowEdits)) { - (updatedRow as Record)[field] = edit.currentValue; + const openMediaEditor = useCallback( + (rowIds: string[]) => { + const uniqueRowIds = Array.from(new Set(rowIds)); + const firstRow = displayRows.find((row) => row.id === uniqueRowIds[0]); + + if (!firstRow || !isFieldEditable("media")) { + return; } - // Recalculate available if any inventory field was edited - if (rowEdits.onHand || rowEdits.unavailable) { - updatedRow.available = - updatedRow.onHand - updatedRow.unavailable - updatedRow.reserved; + const bulk = uniqueRowIds.length > 1; + const initialSelectedMedia = bulk ? [] : firstRow.media; + const initialSelectedIds = initialSelectedMedia.map((file) => file.id); + const initialSelectedIdSet = new Set(initialSelectedIds); + const gallery = [ + ...initialSelectedMedia, + ...productMediaFiles.filter((file) => !initialSelectedIdSet.has(file.id)), + ]; + + openEditMediaModal({ + title: bulk ? "Edit variants media" : "Edit variant media", + galleryTitle: "Variant Media", + featured: initialSelectedMedia[0] ?? null, + gallery, + selectionMode: true, + selectedFileIds: initialSelectedIds, + showUpload: false, + allowDelete: false, + allowSetFeatured: true, + hasFeatured: true, + onSave: ( + media: Parameters>[0], + ) => { + const selectedMedia = media.gallery; + + for (const rowId of uniqueRowIds) { + const originalRow = rows.find((row) => row.id === rowId); + + if (!originalRow) { + continue; + } + + setFieldValue(rowId, "media", originalRow.media, selectedMedia); + } + + return true; + }, + }); + }, + [ + displayRows, + isFieldEditable, + openEditMediaModal, + productMediaFiles, + rows, + setFieldValue, + ], + ); + + const handleOpenMediaEditor = useCallback( + (rowId: string, selectedRowIds?: string[]) => { + openMediaEditor(selectedRowIds?.length ? selectedRowIds : [rowId]); + }, + [openMediaEditor], + ); + + const handleSelectionEnter = useCallback( + (cells: ICellSelection[]) => { + const mediaCells = cells.filter((cell) => cell.field === "media"); + + if (mediaCells.length === 0) { + return false; } - return updatedRow; - }); - }, [rows, edits]); + openMediaEditor(mediaCells.map((cell) => cell.rowId)); + return true; + }, + [openMediaEditor], + ); + + const handleOptionValueChange = useCallback( + (rowId: string, optionId: string, optionValueId: string) => { + const row = displayRows.find((candidate) => candidate.id === rowId); + const originalRow = rows.find((candidate) => candidate.id === rowId); + + if (!row || !originalRow) { + return; + } + + setFieldValue(rowId, "selectedOptionValueIds", originalRow.selectedOptionValueIds, { + ...row.selectedOptionValueIds, + [optionId]: optionValueId, + }); + }, + [displayRows, rows, setFieldValue], + ); + + const handleDeleteRow = useCallback( + (rowId: string) => { + const row = displayRows.find((candidate) => candidate.id === rowId); - // Notify parent of changes + if (!row) { + return; + } + + deleteVariantRow(row); + }, + [deleteVariantRow, displayRows], + ); + + // Columns - pass availableColumns and ignoreUserSettings + const columns = useVariantsColumns({ + optionGroups, + productOptions, + currency, + availableColumns, + editableColumns, + ignoreUserSettings, + onEditMedia: isFieldEditable("media") ? handleOpenMediaEditor : undefined, + onOptionValueChange: handleOptionValueChange, + onDeleteRow: allowDeleteRows ? handleDeleteRow : undefined, + }); + + // Notify compatible external consumers of store-owned rows. useEffect(() => { - if (Object.keys(edits).length > 0) { + if ( + Object.keys(edits).length > 0 || + draftRows.length > 0 || + materializedRows.length > 0 || + deletedExistingRows.length > 0 + ) { onChange?.(displayRows); } - }, [displayRows, edits, onChange]); + }, [ + displayRows, + deletedExistingRows.length, + draftRows.length, + edits, + materializedRows.length, + onChange, + ]); // Handle field value change with validation const handleSetFieldValue = useCallback( (rowId: string, field: string, originalValue: unknown, newValue: unknown) => { - // Validate inventory fields using shared validator - if (field === "onHand" || field === "unavailable") { - const row = rows.find((r) => r.id === rowId); - if (row) { - const result = validateFieldChange(field, Number(newValue), { - onHand: row.onHand, - unavailable: row.unavailable, - reserved: row.reserved, - available: row.available, - }); - if (!result.isValid) { - message.error(result.errors[0]?.message || "Invalid value"); - return; - } + if (!isFieldEditable(field)) { + return; + } + + if (field === "media") { + if (newValue === null) { + setFieldValue(rowId, field, originalValue, []); + } else if (Array.isArray(newValue)) { + setFieldValue(rowId, field, originalValue, newValue); } + + return; } setFieldValue(rowId, field, originalValue, newValue); }, - [setFieldValue, rows, message] + [isFieldEditable, setFieldValue] ); return ( @@ -211,8 +364,10 @@ export const VariantsEditorGrid: React.FC = ({ rows={rows} displayRows={displayRows} columns={columns} - selectableColumns={SELECTABLE_COLUMNS} + selectableColumns={selectableColumns} onSetFieldValue={handleSetFieldValue} + onSelectionEnter={handleSelectionEnter} + dataTestId={dataTestId} /> ); }; diff --git a/admin/src/domains/inventory/products/components/variants/config/columns.ts b/admin/src/domains/inventory/products/components/variants/config/columns.ts index 0b415f037..9cdad2bfb 100644 --- a/admin/src/domains/inventory/products/components/variants/config/columns.ts +++ b/admin/src/domains/inventory/products/components/variants/config/columns.ts @@ -9,7 +9,7 @@ export const MEDIA_COLUMNS: IVariantColumn[] = [ field: "media", headerName: "Variant Media", defaultVisible: true, - editable: false, + editable: true, width: 120, minWidth: 80, type: "media", @@ -33,66 +33,6 @@ export const PRICING_COLUMNS: IVariantColumn[] = [ width: 130, type: "number", }, - { - field: "costPrice", - headerName: "Cost", - defaultVisible: false, - editable: true, - width: 110, - type: "number", - }, -]; - -export const INVENTORY_COLUMNS: IVariantColumn[] = [ - { - field: "sku", - headerName: "SKU", - defaultVisible: true, - editable: true, - width: 150, - minWidth: 150, - type: "text", - }, - { - field: "barcode", - headerName: "Barcode", - defaultVisible: false, - editable: true, - width: 140, - type: "text", - }, - { - field: "onHand", - headerName: "On hand", - defaultVisible: true, - editable: true, - width: 100, - type: "number", - }, - { - field: "unavailable", - headerName: "Unavailable", - defaultVisible: true, - editable: true, - width: 110, - type: "number", - }, - { - field: "reserved", - headerName: "Reserved", - defaultVisible: true, - editable: false, // managed by order system - width: 100, - type: "number", - }, - { - field: "available", - headerName: "Available", - defaultVisible: true, - editable: false, // calculated - width: 100, - type: "number", - }, ]; export const ATTRIBUTES_COLUMNS: IVariantColumn[] = [ @@ -133,7 +73,6 @@ export const ATTRIBUTES_COLUMNS: IVariantColumn[] = [ // Combined for backwards compatibility export const VARIANT_COLUMNS: IVariantColumn[] = [ ...PRICING_COLUMNS, - ...INVENTORY_COLUMNS, ...ATTRIBUTES_COLUMNS, ]; @@ -158,13 +97,9 @@ export function createOptionColumns(optionGroups: IOptionGroup[]): IVariantColum // ============================================================================ export const SELECTABLE_COLUMNS = [ + "media", "price", "compareAtPrice", - "costPrice", - "onHand", - "unavailable", - "sku", - "barcode", "weight", "length", "width", diff --git a/admin/src/domains/inventory/products/components/variants/config/index.ts b/admin/src/domains/inventory/products/components/variants/config/index.ts index fa8aa59ca..2e3d2ddcc 100644 --- a/admin/src/domains/inventory/products/components/variants/config/index.ts +++ b/admin/src/domains/inventory/products/components/variants/config/index.ts @@ -1,17 +1,7 @@ -export type { - IVariantEditorRow, - IVariantColumn, - IVariantOption, - IOptionGroup, - StockStatus, - VariantColumnField, -} from "./types"; - export { VARIANT_COLUMNS, MEDIA_COLUMNS, PRICING_COLUMNS, - INVENTORY_COLUMNS, ATTRIBUTES_COLUMNS, SELECTABLE_COLUMNS, createOptionColumns, diff --git a/admin/src/domains/inventory/products/components/variants/config/types.ts b/admin/src/domains/inventory/products/components/variants/config/types.ts index 1f2cea653..12b7a1826 100644 --- a/admin/src/domains/inventory/products/components/variants/config/types.ts +++ b/admin/src/domains/inventory/products/components/variants/config/types.ts @@ -1,39 +1,56 @@ -import type { IEditorRowBase } from "@/shared/components/editor-grid"; +import type { IEditorRowBase } from "@/shared/components/editor-grid/types"; +import type { ApiFile } from "@/graphql/types"; // ============================================================================ // Variant Row Interface // ============================================================================ export type StockStatus = "in_stock" | "low_stock" | "out_of_stock"; +export type VariantEditorRowKind = "existing" | "draft" | "blank"; export interface IVariantOption { + optionId?: string; + optionValueId?: string; name: string; value: string; } +export interface IVariantEditorInput { + id: string; + kind?: VariantEditorRowKind; + clientMutationId?: string; + title: string; + imageUrl?: string | null; + media?: ApiFile[] | null; + options?: IVariantOption[]; + selectedOptionValueIds?: Record; + price?: number | null; + compareAtPrice?: number | null; + weight?: number | null; + weightUnit?: string; + length?: number | null; + width?: number | null; + height?: number | null; + dimensionUnit?: string; +} + export interface IVariantEditorRow extends IEditorRowBase { + kind?: VariantEditorRowKind; + clientMutationId?: string; + // Display title: string; imageUrl: string | null; - media: string[] | null; + media: ApiFile[]; + rowError?: string | null; // Options (dynamic, based on product options) options: IVariantOption[]; - - // Inventory identification - sku: string | null; - barcode: string | null; - - // Inventory quantities (same model as inventory table) - onHand: number; - unavailable: number; - reserved: number; - available: number; // calculated: onHand - unavailable - reserved + selectedOptionValueIds: Record; // Pricing - price: number; + price: number | null; compareAtPrice: number | null; - costPrice: number | null; // Shipping weight: number | null; @@ -44,6 +61,15 @@ export interface IVariantEditorRow extends IEditorRowBase { dimensionUnit: string; } +export interface VariantOptionRowsValidationResult { + rows: IVariantEditorRow[]; + hasErrors: boolean; + duplicateRowIds: string[]; + incompleteRowIds: string[]; + invalidRowIds: string[]; + messages: string[]; +} + // ============================================================================ // Column Configuration // ============================================================================ @@ -65,15 +91,8 @@ export interface IVariantColumn { export type VariantColumnField = | "media" - | "sku" - | "barcode" | "price" | "compareAtPrice" - | "costPrice" - | "onHand" - | "unavailable" - | "reserved" - | "available" | "weight" | "length" | "width" diff --git a/admin/src/domains/inventory/products/components/variants/edit-variants-modal.tsx b/admin/src/domains/inventory/products/components/variants/edit-variants-modal.tsx index 82c3a928a..ba2030ae5 100644 --- a/admin/src/domains/inventory/products/components/variants/edit-variants-modal.tsx +++ b/admin/src/domains/inventory/products/components/variants/edit-variants-modal.tsx @@ -1,18 +1,31 @@ "use client"; -import { useEffect, useCallback, useRef, useMemo } from "react"; -import { Divider, Tag } from "antd"; +import { useEffect, useCallback, useMemo } from "react"; +import { Divider, Tag, App } from "antd"; import { createStyles } from "antd-style"; import { ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { useDefaultCurrency } from "@/domains/workspace"; import { useVariantsEditorStore } from "./hooks"; +import { VariantsColumnSettings } from "./components/variants-column-settings"; import { - VariantsEditorGrid, - VariantsColumnSettings, extractOptionGroups, - type IVariantInput, -} from "./components"; -import type { IVariantEditorRow, IOptionGroup } from "./config"; + VariantsEditorGrid, +} from "./components/variants-editor-grid"; +import type { + IVariantEditorRow, + IOptionGroup, +} from "./config/types"; import type { IEditVariantsModalPayload } from "../../modals"; +import { + apiVariantsToVariantOptionRows, + validateVariantOptionRows, + variantOptionRowsToProductUpdateInput, +} from "../../mappers"; +import { mapVariantOperationResultsToRowState } from "../../mappers/product-errors.mapper"; +import { + mapApiVariantsToEditorInputs, + mapVariantEditorInputsToRows, +} from "../../mappers/product-variant-editor.mapper"; // ============================================================================ // Styles @@ -61,128 +74,202 @@ const useStyles = createStyles(() => ({ }, })); -// ============================================================================ -// Helpers -// ============================================================================ - -function transformVariantsToInput( - variants: IEditVariantsModalPayload["variants"] -): IVariantInput[] { - return variants.map((v) => ({ - id: v.id, - title: v.title, - imageUrl: v.imageUrl, - media: v.media, - options: - v.options?.map((o) => ({ name: o.group.title, value: o.title })) || [], - sku: v.sku, - barcode: v.barcode, - // Inventory quantities - onHand: v.onHand ?? 0, - unavailable: v.unavailable ?? 0, - reserved: v.reserved ?? 0, - price: v.price, - compareAtPrice: v.compareAtPrice, - costPrice: v.costPrice, - weight: v.weight, - weightUnit: v.weightUnit, - length: v.length, - width: v.width, - height: v.height, - dimensionUnit: v.dimensionUnit, - })); -} - -function getDataForSave(rows: IVariantEditorRow[]) { - return rows.map((row) => ({ - id: row.id, - sku: row.sku, - barcode: row.barcode, - // Inventory quantities - onHand: row.onHand, - unavailable: row.unavailable, - reserved: row.reserved, - available: row.available, - price: row.price, - compareAtPrice: row.compareAtPrice, - costPrice: row.costPrice, - weight: row.weight, - weightUnit: row.weightUnit, - length: row.length, - width: row.width, - height: row.height, - dimensionUnit: row.dimensionUnit, - })); -} - // ============================================================================ // Main Component // ============================================================================ export const EditVariantsModal = () => { const { styles } = useStyles(); - const { payload, pop, setDirty } = useModalStackContext(); + const { message } = App.useApp(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); const typedPayload = payload as IEditVariantsModalPayload; + const storeDefaultCurrency = useDefaultCurrency(); + const defaultCurrency = + typedPayload.defaultCurrency ?? storeDefaultCurrency ?? null; + const allowDraftRows = typedPayload.allowDraftRows ?? true; + const allowDeleteRows = typedPayload.allowDeleteRows ?? allowDraftRows; // Store const hasChanges = useVariantsEditorStore((s) => s.hasChanges()); const changesCount = useVariantsEditorStore((s) => s.getChangesCount()); const status = useVariantsEditorStore((s) => s.status); - - const { resetEdits } = useVariantsEditorStore.getState(); + const initializeSession = useVariantsEditorStore((s) => s.initializeSession); + const resetSession = useVariantsEditorStore((s) => s.resetSession); + const startSaving = useVariantsEditorStore((s) => s.startSaving); + const onSaveSuccess = useVariantsEditorStore((s) => s.onSaveSuccess); + const onSaveError = useVariantsEditorStore((s) => s.onSaveError); + const setRowErrors = useVariantsEditorStore((s) => s.setRowErrors); + const commitDeletedRows = useVariantsEditorStore((s) => s.commitDeletedRows); + const restoreDeletedRows = useVariantsEditorStore((s) => s.restoreDeletedRows); + const materializeDraftRows = useVariantsEditorStore( + (s) => s.materializeDraftRows, + ); + const getCurrentRows = useVariantsEditorStore((s) => s.getCurrentRows); + const getRowsForSave = useVariantsEditorStore((s) => s.getRowsForSave); const isSaving = status === "saving"; // Column restrictions from payload const availableColumns = typedPayload.availableColumns; + const editableColumns = typedPayload.editableColumns; const showColumnSettings = typedPayload.showColumnSettings ?? true; // Transform variants to input format const variantInputs = useMemo( - () => transformVariantsToInput(typedPayload.variants), - [typedPayload.variants] + () => + mapApiVariantsToEditorInputs( + typedPayload.variants, + typedPayload.productOptions, + { productMediaFiles: typedPayload.productMediaFiles }, + ), + [ + typedPayload.productOptions, + typedPayload.productMediaFiles, + typedPayload.variants, + ], ); // Extract option groups for column settings const optionGroups = useMemo( - () => extractOptionGroups(variantInputs), - [variantInputs] + () => extractOptionGroups(variantInputs, typedPayload.productOptions), + [typedPayload.productOptions, variantInputs] ); - // Track current rows for save - const rowDataRef = useRef([]); + const originalOptionRows = useMemo( + () => apiVariantsToVariantOptionRows( + typedPayload.variants, + typedPayload.productOptions, + ), + [typedPayload.productOptions, typedPayload.variants], + ); + + const baseRows = useMemo( + () => mapVariantEditorInputsToRows(variantInputs), + [variantInputs], + ); // Sync dirty state useEffect(() => { - // setDirty(hasChanges); + setDirty(hasChanges); }, [hasChanges, setDirty]); - // Reset edits on mount/unmount + // Reset session on mount/unmount useEffect(() => { - resetEdits(); + initializeSession({ includeBlankRow: allowDraftRows }); return () => { - resetEdits(); + resetSession(); }; - }, [resetEdits]); - - // Handle row changes - const handleChange = useCallback((rows: IVariantEditorRow[]) => { - rowDataRef.current = rows; - }, []); + }, [allowDraftRows, initializeSession, resetSession]); // Handle close const handleClose = useCallback(() => { - resetEdits(); + resetSession(); pop(); - }, [resetEdits, pop]); + }, [resetSession, pop]); // Handle save - const handleSave = useCallback(() => { - const dataForSave = getDataForSave(rowDataRef.current); - typedPayload.onSave?.(dataForSave); - resetEdits(); - pop(); - }, [typedPayload, resetEdits, pop]); + const handleSave = useCallback(async () => { + const currentRows = getCurrentRows(baseRows).filter( + (row) => row.kind !== "blank", + ); + const optionValidation = validateVariantOptionRows( + currentRows, + typedPayload.productOptions, + ); + + if (optionValidation.hasErrors) { + const validationRowErrors: Record = {}; + + for (const row of optionValidation.rows as Array< + IVariantEditorRow & { validationMessage?: string | null } + >) { + if (row.validationMessage) { + validationRowErrors[row.id] = row.validationMessage; + } + } + + setRowErrors(validationRowErrors); + message.error(optionValidation.messages[0] ?? "Variant options are invalid."); + return; + } + + setRowErrors({}); + const { existingRows, draftRows, deletedRows } = getRowsForSave(baseRows); + const existingCurrentRows = currentRows.filter( + (row) => row.kind !== "draft", + ); + const optionOperations = variantOptionRowsToProductUpdateInput( + existingCurrentRows, + originalOptionRows, + typedPayload.productOptions, + ); + const additionalOperations = optionOperations.variants?.length + ? optionOperations + : undefined; + + startSaving(); + + try { + const result = await typedPayload.onSave?.({ + existingRows, + draftRows, + deletedRows, + additionalOperations, + }); + + if (!result || result.ok) { + onSaveSuccess(); + setDirty(false); + forcePop(); + return; + } + + const rowState = mapVariantOperationResultsToRowState({ + existingRows, + draftRows, + deletedRows, + additionalOperations, + submittedVariantOperations: result.submittedVariantOperations, + operationResults: result.operationResults, + userErrors: result.userErrors, + }); + + materializeDraftRows(rowState.materializedDraftRows); + commitDeletedRows(rowState.appliedDeletedRowIds); + restoreDeletedRows(rowState.rowErrors); + setRowErrors(rowState.rowErrors); + onSaveError(); + + message.error( + rowState.firstMessage ?? + result.userErrors[0]?.message ?? + "Variant changes could not be saved.", + ); + } catch (err) { + onSaveError(); + message.error( + err instanceof Error + ? err.message + : "Variant changes could not be saved.", + ); + } + }, [ + baseRows, + getCurrentRows, + getRowsForSave, + message, + originalOptionRows, + commitDeletedRows, + materializeDraftRows, + restoreDeletedRows, + setRowErrors, + typedPayload, + startSaving, + onSaveError, + onSaveSuccess, + forcePop, + setDirty, + ]); // Handle escape key useEffect(() => { @@ -199,7 +286,7 @@ export const EditVariantsModal = () => { const headerExtra = showColumnSettings ? (
    - +
    ) : null; @@ -215,7 +302,7 @@ export const EditVariantsModal = () => { submitButtonProps: { onClick: handleSave, loading: isSaving, - disabled: !hasChanges, + disabled: isSaving || !hasChanges, children: ( <> Save @@ -231,9 +318,14 @@ export const EditVariantsModal = () => {
    diff --git a/admin/src/domains/inventory/products/components/variants/hooks/index.ts b/admin/src/domains/inventory/products/components/variants/hooks/index.ts index 1cd6158e7..4f7161dfb 100644 --- a/admin/src/domains/inventory/products/components/variants/hooks/index.ts +++ b/admin/src/domains/inventory/products/components/variants/hooks/index.ts @@ -1,10 +1,8 @@ export { useVariantsEditorStore, DEFAULT_VARIANTS_COLUMN_VISIBILITY, - type IColumnVisibility, } from "./use-variants-editor-store"; export { useVariantsColumns, - type UseVariantsColumnsOptions, } from "./use-variants-columns"; diff --git a/admin/src/domains/inventory/products/components/variants/hooks/use-variants-columns.tsx b/admin/src/domains/inventory/products/components/variants/hooks/use-variants-columns.tsx index 4d55959d2..824c9d387 100644 --- a/admin/src/domains/inventory/products/components/variants/hooks/use-variants-columns.tsx +++ b/admin/src/domains/inventory/products/components/variants/hooks/use-variants-columns.tsx @@ -1,23 +1,30 @@ -import { useMemo } from "react"; +import { useMemo, useRef, useState } from "react"; +import { Dropdown } from "antd"; +import { LuChevronDown as DownOutlined } from "react-icons/lu"; +import type { MenuProps } from "antd"; import type { ColDef, ValueGetterParams, ValueSetterParams } from "ag-grid-community"; import { useVariantsEditorStore } from "./use-variants-editor-store"; import { VARIANT_COLUMNS, MEDIA_COLUMNS, createOptionColumns, - type IVariantEditorRow, - type IOptionGroup, - type VariantColumnField, } from "../config"; +import type { + IVariantEditorRow, + IOptionGroup, + VariantColumnField, +} from "../config/types"; +import type { ApiProductOption, CurrencyCode } from "@/graphql/types"; +import { Dash } from "@/shared/components/editor-grid"; import { ImageCellRenderer, TitleCellRenderer, TextCellRenderer, NumberCellRenderer, PriceCellRenderer, - ReservedCellRenderer, - AvailableCellRenderer, + ActionsCellRenderer, } from "../components/cell-renderers"; +import { formatCurrencySymbol } from "../../../utils/price-formatting"; // ============================================================================ // Types @@ -25,33 +32,118 @@ import { export interface UseVariantsColumnsOptions { optionGroups: IOptionGroup[]; + productOptions?: ApiProductOption[]; + currency?: CurrencyCode | null; /** * When provided, only these columns will be available. * If undefined, all columns are available (with user visibility settings). */ availableColumns?: VariantColumnField[]; + /** + * When provided, controls which available columns can be edited. + * If undefined, each column uses its own config editability. + */ + editableColumns?: VariantColumnField[]; /** * When true, column visibility is controlled by availableColumns only, * ignoring user settings. Useful for restricted views. */ ignoreUserSettings?: boolean; + onEditMedia?: (rowId: string, selectedRowIds?: string[]) => void; + onOptionValueChange?: ( + rowId: string, + optionId: string, + optionValueId: string, + ) => void; + onDeleteRow?: (rowId: string) => void; +} + +interface OptionDropdownCellProps { + row: IVariantEditorRow; + option: ApiProductOption; + onChange?: (rowId: string, optionId: string, optionValueId: string) => void; +} + +function OptionDropdownCell({ + row, + option, + onChange, +}: OptionDropdownCellProps) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const selectedValueId = row.selectedOptionValueIds[option.id]; + const selectedValue = option.values.find((value) => value.id === selectedValueId); + const items: MenuProps["items"] = [...option.values] + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((value) => ({ + key: value.id, + label: value.name, + })); + + return ( + { + onChange?.(row.id, option.id, key); + setOpen(false); + }, + }} + trigger={["contextMenu"]} + open={open} + onOpenChange={(visible) => { + if (!visible) setOpen(false); + }} + popupRender={(menu) => ( +
    {menu}
    + )} + > +
    setOpen(true)} + style={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "0 11px", + gap: 8, + }} + data-testid={`variants-editor-cell-option-${option.id}-${row.id}`} + > + + {selectedValue?.name ?? } + + +
    +
    + ); } // ============================================================================ // Price fields // ============================================================================ -const PRICE_FIELDS = new Set(["price", "compareAtPrice", "costPrice"]); +const PRICE_FIELDS = new Set(["price", "compareAtPrice"]); + +function getColumnHeaderName( + headerName: string, + field: string, + currency: CurrencyCode | null | undefined, +): string { + if (PRICE_FIELDS.has(field) && currency) { + return `${headerName} (${formatCurrencySymbol(currency)})`; + } + + return headerName; +} // ============================================================================ // Get cell renderer based on column type and field // ============================================================================ -function getCellRenderer(type?: string, field?: string) { - // Special renderers for inventory fields - if (field === "reserved") return ReservedCellRenderer; - if (field === "available") return AvailableCellRenderer; - +function getCellRenderer(type?: string) { switch (type) { case "number": return NumberCellRenderer; @@ -98,16 +190,12 @@ function getCellEditorParams(field: string) { switch (field) { case "price": case "compareAtPrice": - case "costPrice": return { min: 0, precision: 2 }; - case "onHand": - case "unavailable": - return { min: 0, precision: 0 }; case "weight": case "length": case "width": case "height": - return { min: 0, precision: 2 }; + return { min: 1, precision: 0 }; default: return undefined; } @@ -136,10 +224,10 @@ function createValueGetter(field: string) { // Validation is done in handleSetFieldValue in VariantsEditorGrid // ============================================================================ -function createValueSetter(field: string) { +function createValueSetter(field: string, editable: boolean) { return (params: ValueSetterParams): boolean => { const { data, newValue } = params; - if (!data) return false; + if (!data || !editable) return false; const originalValue = (data as unknown as Record)[field]; @@ -169,7 +257,17 @@ export function useVariantsColumns( ? { optionGroups: optionsOrOptionGroups } : optionsOrOptionGroups; - const { optionGroups, availableColumns, ignoreUserSettings = false } = normalizedOptions; + const { + optionGroups, + productOptions = [], + availableColumns, + editableColumns, + ignoreUserSettings = false, + currency, + onEditMedia, + onOptionValueChange, + onDeleteRow, + } = normalizedOptions; const columnVisibility = useVariantsEditorStore((s) => s.columnVisibility); const isOptionColumnVisible = useVariantsEditorStore( @@ -203,28 +301,67 @@ export function useVariantsColumns( columns.push({ field: col.field as keyof IVariantEditorRow, - headerName: col.headerName, + headerName: getColumnHeaderName( + col.headerName, + col.field, + currency, + ), width: col.width, minWidth: 80, cellRenderer: ImageCellRenderer, + cellRendererParams: { + onEditMedia, + }, }); } // Option columns (dynamic) - only show when not restricted or when user settings allow if (!ignoreUserSettings) { + const productOptionsByName = new Map( + productOptions.map((option) => [option.name, option]), + ); const optionCols = createOptionColumns(optionGroups); for (const col of optionCols) { if (!isOptionColumnVisible(col.headerName)) continue; const optionName = col.headerName; + const productOption = productOptionsByName.get(optionName); columns.push({ colId: col.field, headerName: col.headerName, width: col.width, minWidth: col.minWidth, + cellClass: productOption ? "variant-option-cell-dropdown" : undefined, valueGetter: (params) => { - const option = params.data?.options.find((o) => o.name === optionName); - return option?.value ?? ""; + if (productOption) { + return params.data?.selectedOptionValueIds[productOption.id] ?? null; + } + + const option = params.data?.options.find((item) => item.name === optionName); + return option?.value ?? null; + }, + cellRenderer: (params: { data?: IVariantEditorRow; value?: unknown }) => { + if (!params.data) { + return null; + } + + if (productOption) { + return ( + + ); + } + + return ( + + {params.value === null || params.value === undefined || params.value === "" + ? + : String(params.value)} + + ); }, }); } @@ -245,24 +382,70 @@ export function useVariantsColumns( // Use appropriate renderer based on field type const cellRenderer = PRICE_FIELDS.has(col.field) ? PriceCellRenderer - : getCellRenderer(col.type, col.field); + : getCellRenderer(col.type); + const isEditable = + col.editable && + (!editableColumns || + editableColumns.includes(col.field as VariantColumnField)); columns.push({ field: col.field as keyof IVariantEditorRow, - headerName: col.headerName, + headerName: getColumnHeaderName( + col.headerName, + col.field, + currency, + ), + colId: PRICE_FIELDS.has(col.field) + ? `${col.field}-${currency ?? "none"}` + : col.field, width: col.width, minWidth: col.minWidth, flex: col.flex, type: getColumnType(col.type), - editable: col.editable, + editable: isEditable, cellRenderer, + cellRendererParams: PRICE_FIELDS.has(col.field) + ? { + currency, + } + : undefined, cellEditor: getCellEditor(col.type), cellEditorParams: getCellEditorParams(col.field), valueGetter: createValueGetter(col.field), - valueSetter: createValueSetter(col.field), + valueSetter: createValueSetter(col.field, isEditable), + }); + } + + if (onDeleteRow) { + columns.push({ + colId: "actions", + headerName: "", + pinned: "right", + width: 56, + minWidth: 56, + maxWidth: 56, + resizable: false, + sortable: false, + suppressNavigable: true, + cellRenderer: ActionsCellRenderer, + cellRendererParams: { + onDeleteRow, + }, }); } return columns; - }, [columnVisibility, optionGroups, isOptionColumnVisible, availableColumns, ignoreUserSettings]); + }, [ + columnVisibility, + optionGroups, + productOptions, + isOptionColumnVisible, + availableColumns, + editableColumns, + ignoreUserSettings, + currency, + onEditMedia, + onOptionValueChange, + onDeleteRow, + ]); } diff --git a/admin/src/domains/inventory/products/components/variants/hooks/use-variants-editor-store.ts b/admin/src/domains/inventory/products/components/variants/hooks/use-variants-editor-store.ts index fef8c0efb..42e32282a 100644 --- a/admin/src/domains/inventory/products/components/variants/hooks/use-variants-editor-store.ts +++ b/admin/src/domains/inventory/products/components/variants/hooks/use-variants-editor-store.ts @@ -1,6 +1,19 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; -import type { IFieldEdit, IRowEdits } from "@/shared/components/editor-grid"; +import type { ApiGenericUserError } from "@/graphql/types"; +import type { + IFieldEdit, + IRowEdits, +} from "@/shared/components/editor-grid/types"; +import type { + IVariantEditorRow, + VariantEditorRowKind, +} from "../config/types"; +import type { VariantEditorSaveRow } from "../../../mappers/product-variant-editor.mapper"; +import { + DEFAULT_DIMENSION_UNIT, + DEFAULT_WEIGHT_UNIT, +} from "../../../utils/product-measurements"; // ============================================================================ // Types @@ -10,28 +23,185 @@ export interface IColumnVisibility { [field: string]: boolean; } +export type VariantsEditorStatus = "idle" | "saving" | "error"; + +export interface VariantDraftMaterializationResult { + clientMutationId: string; + entityId: string; + applied: boolean; + errors: ApiGenericUserError[]; +} + +export interface InitializeVariantsEditorSessionOptions { + includeBlankRow?: boolean; +} + // Default column visibility for variants editor export const DEFAULT_VARIANTS_COLUMN_VISIBILITY: IColumnVisibility = { - // Media column media: true, - // Options columns are always visible by default (handled dynamically) - // Variant columns - sku: true, - barcode: false, price: true, compareAtPrice: false, - costPrice: false, - // Inventory columns (same model as inventory table) - onHand: true, - unavailable: true, - reserved: true, - available: true, weight: false, length: false, width: false, height: false, }; +const DRAFT_ROW_PREFIX = "draft:"; +const BLANK_ROW_PREFIX = "blank:"; + +function createEditorId(prefix: string): string { + const random = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + + return `${prefix}${random}`; +} + +function createEmptyEditorRow(kind: VariantEditorRowKind): IVariantEditorRow { + const isDraft = kind === "draft"; + + return { + id: createEditorId(isDraft ? DRAFT_ROW_PREFIX : BLANK_ROW_PREFIX), + kind, + clientMutationId: isDraft + ? createEditorId("variant-create:") + : undefined, + title: isDraft ? "New variant" : "Add variant", + imageUrl: null, + media: [], + options: [], + selectedOptionValueIds: {}, + price: null, + compareAtPrice: null, + weight: null, + weightUnit: DEFAULT_WEIGHT_UNIT, + length: null, + width: null, + height: null, + dimensionUnit: DEFAULT_DIMENSION_UNIT, + rowError: null, + }; +} + +function normalizeComparableValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => { + if (item && typeof item === "object" && "id" in item) { + return (item as { id: unknown }).id; + } + + return item; + }); + } + + return value; +} + +function areFieldValuesEqual(left: unknown, right: unknown): boolean { + const comparableLeft = normalizeComparableValue(left); + const comparableRight = normalizeComparableValue(right); + + if (Array.isArray(comparableLeft) && Array.isArray(comparableRight)) { + return ( + comparableLeft.length === comparableRight.length && + comparableLeft.every((item, index) => item === comparableRight[index]) + ); + } + + return comparableLeft === comparableRight; +} + +function isMeaningfulBlankValue(value: unknown): boolean { + if (Array.isArray(value)) { + return value.length > 0; + } + + if (value && typeof value === "object") { + return Object.values(value as Record).some((item) => + isMeaningfulBlankValue(item), + ); + } + + return value !== null && value !== undefined && value !== ""; +} + +function applyRowPatch( + row: IVariantEditorRow, + patch: Partial, +): IVariantEditorRow { + const updatedRow = { + ...row, + ...patch, + }; + + if (patch.media) { + updatedRow.imageUrl = patch.media[0]?.url ?? null; + } + + return updatedRow; +} + +function rowToSaveRow(row: IVariantEditorRow): VariantEditorSaveRow { + return { + id: row.id, + kind: row.kind, + clientMutationId: row.clientMutationId, + selectedOptionValueIds: row.selectedOptionValueIds, + price: row.price, + compareAtPrice: row.compareAtPrice, + weight: row.weight, + weightUnit: row.weightUnit, + length: row.length, + width: row.width, + height: row.height, + dimensionUnit: row.dimensionUnit, + mediaFileIds: row.media.map((file) => file.id), + }; +} + +function withRowError( + row: IVariantEditorRow, + rowErrors: Record, +): IVariantEditorRow { + return { + ...row, + rowError: rowErrors[row.id] ?? null, + }; +} + +function applyRowEdits( + row: IVariantEditorRow, + rowEdits: IRowEdits | undefined, +): IVariantEditorRow { + if (!rowEdits) { + return row; + } + + const updatedRow = { ...row }; + for (const [field, edit] of Object.entries(rowEdits)) { + (updatedRow as Record)[field] = edit.currentValue; + } + + if (rowEdits.media) { + const media = rowEdits.media.currentValue; + updatedRow.imageUrl = Array.isArray(media) + ? media[0]?.url ?? null + : updatedRow.imageUrl; + } + + return updatedRow; +} + +function getOperationErrorMessage(errors: ApiGenericUserError[]): string | null { + if (errors.length === 0) { + return null; + } + + return errors.map((error) => error.message).join(" "); +} + // ============================================================================ // Store Interface // ============================================================================ @@ -39,9 +209,19 @@ export const DEFAULT_VARIANTS_COLUMN_VISIBILITY: IColumnVisibility = { interface VariantsEditorStore { // State edits: Record; + draftRows: IVariantEditorRow[]; + materializedRows: IVariantEditorRow[]; + deletedExistingRows: IVariantEditorRow[]; + committedDeletedRowIds: string[]; + blankRow: IVariantEditorRow | null; + rowErrors: Record; columnVisibility: IColumnVisibility; optionColumnVisibility: Record; - status: "idle" | "saving"; + status: VariantsEditorStatus; + + // Actions - Session + initializeSession: (options?: InitializeVariantsEditorSessionOptions) => void; + resetSession: () => void; // Actions - Editing setFieldValue: ( @@ -53,6 +233,16 @@ interface VariantsEditorStore { discardAll: () => void; discardRow: (rowId: string) => void; resetEdits: () => void; + addDraftRow: () => void; + updateDraftRow: (rowId: string, patch: Partial) => void; + removeDraftRow: (rowId: string) => void; + deleteVariantRow: (row: IVariantEditorRow) => void; + commitDeletedRows: (rowIds: string[]) => void; + restoreDeletedRows: (rowErrors: Record) => void; + materializeDraftRows: ( + results: VariantDraftMaterializationResult[], + ) => void; + setRowErrors: (errors: Record) => void; // Actions - Saving startSaving: () => void; @@ -69,6 +259,12 @@ interface VariantsEditorStore { // Selectors hasChanges: () => boolean; getChangesCount: () => number; + getCurrentRows: (baseRows: IVariantEditorRow[]) => IVariantEditorRow[]; + getRowsForSave: (baseRows: IVariantEditorRow[]) => { + existingRows: VariantEditorSaveRow[]; + draftRows: VariantEditorSaveRow[]; + deletedRows: VariantEditorSaveRow[]; + }; getRowEdits: (rowId: string) => IRowEdits | undefined; getFieldEdit: (rowId: string, field: string) => IFieldEdit | undefined; isColumnVisible: (field: string) => boolean; @@ -85,23 +281,101 @@ export const useVariantsEditorStore = create()( (set, get) => ({ // Initial state edits: {}, + draftRows: [], + materializedRows: [], + deletedExistingRows: [], + committedDeletedRowIds: [], + blankRow: null, + rowErrors: {}, columnVisibility: DEFAULT_VARIANTS_COLUMN_VISIBILITY, optionColumnVisibility: {}, status: "idle", + // Session actions + initializeSession: (options) => + set({ + edits: {}, + draftRows: [], + materializedRows: [], + deletedExistingRows: [], + committedDeletedRowIds: [], + blankRow: options?.includeBlankRow + ? createEmptyEditorRow("blank") + : null, + rowErrors: {}, + status: "idle", + }), + resetSession: () => + set({ + edits: {}, + draftRows: [], + materializedRows: [], + deletedExistingRows: [], + committedDeletedRowIds: [], + blankRow: null, + rowErrors: {}, + status: "idle", + }), + // Edit actions setFieldValue: (rowId, field, originalValue, newValue) => { + const draftRow = get().draftRows.find((row) => row.id === rowId); + const blankRow = get().blankRow; + + if (blankRow?.id === rowId) { + if (!isMeaningfulBlankValue(newValue)) { + return; + } + + const draft = applyRowPatch(createEmptyEditorRow("draft"), { + [field]: newValue, + } as Partial); + + set((state) => ({ + draftRows: [...state.draftRows, draft], + blankRow: createEmptyEditorRow("blank"), + rowErrors: { + ...state.rowErrors, + [draft.id]: null, + [rowId]: null, + }, + })); + return; + } + + if (draftRow) { + set((state) => ({ + draftRows: state.draftRows.map((row) => + row.id === rowId + ? applyRowPatch(row, { + [field]: newValue, + } as Partial) + : row, + ), + rowErrors: { + ...state.rowErrors, + [rowId]: null, + }, + })); + return; + } + set((state) => { const rowEdits = state.edits[rowId] || {}; - // If reverted to original, remove the edit - if (originalValue === newValue) { + if (areFieldValuesEqual(originalValue, newValue)) { const { [field]: _, ...restFields } = rowEdits; if (Object.keys(restFields).length === 0) { const { [rowId]: __, ...restEdits } = state.edits; - return { edits: restEdits }; + return { + edits: restEdits, + rowErrors: { ...state.rowErrors, [rowId]: null }, + }; } - return { edits: { ...state.edits, [rowId]: restFields } }; + return { + edits: { ...state.edits, [rowId]: restFields }, + rowErrors: { ...state.rowErrors, [rowId]: null }, + }; } return { @@ -112,22 +386,192 @@ export const useVariantsEditorStore = create()( [field]: { originalValue, currentValue: newValue }, }, }, + rowErrors: { ...state.rowErrors, [rowId]: null }, }; }); }, - discardAll: () => set({ edits: {} }), + discardAll: () => + set({ + edits: {}, + draftRows: [], + materializedRows: [], + deletedExistingRows: [], + committedDeletedRowIds: [], + rowErrors: {}, + }), discardRow: (rowId) => set((state) => { - const { [rowId]: _, ...rest } = state.edits; - return { edits: rest }; + const { [rowId]: _, ...restEdits } = state.edits; + const { [rowId]: __, ...restErrors } = state.rowErrors; + + return { + edits: restEdits, + draftRows: state.draftRows.filter((row) => row.id !== rowId), + materializedRows: state.materializedRows.filter( + (row) => row.id !== rowId, + ), + deletedExistingRows: state.deletedExistingRows.filter( + (row) => row.id !== rowId, + ), + rowErrors: restErrors, + }; + }), + resetEdits: () => set({ edits: {}, rowErrors: {} }), + addDraftRow: () => + set((state) => ({ + draftRows: [...state.draftRows, createEmptyEditorRow("draft")], + })), + updateDraftRow: (rowId, patch) => + set((state) => ({ + draftRows: state.draftRows.map((row) => + row.id === rowId ? applyRowPatch(row, patch) : row, + ), + })), + removeDraftRow: (rowId) => + set((state) => { + const { [rowId]: _, ...restErrors } = state.rowErrors; + + return { + draftRows: state.draftRows.filter((row) => row.id !== rowId), + rowErrors: restErrors, + }; + }), + deleteVariantRow: (row) => + set((state) => { + const { [row.id]: _, ...restEdits } = state.edits; + const { [row.id]: __, ...restErrors } = state.rowErrors; + + if (row.kind === "blank") { + return {}; + } + + if (row.kind === "draft") { + return { + draftRows: state.draftRows.filter( + (draftRow) => draftRow.id !== row.id, + ), + rowErrors: restErrors, + }; + } + + if ( + state.deletedExistingRows.some( + (deletedRow) => deletedRow.id === row.id, + ) || + state.committedDeletedRowIds.includes(row.id) + ) { + return { + edits: restEdits, + rowErrors: restErrors, + }; + } + + return { + edits: restEdits, + deletedExistingRows: [...state.deletedExistingRows, row], + rowErrors: restErrors, + }; + }), + commitDeletedRows: (rowIds) => + set((state) => { + if (rowIds.length === 0) { + return {}; + } + + const rowIdSet = new Set(rowIds); + + return { + deletedExistingRows: state.deletedExistingRows.filter( + (row) => !rowIdSet.has(row.id), + ), + committedDeletedRowIds: Array.from( + new Set([...state.committedDeletedRowIds, ...rowIds]), + ), + }; }), - resetEdits: () => set({ edits: {} }), + restoreDeletedRows: (rowErrors) => + set((state) => { + const restoreRowIds = new Set( + state.deletedExistingRows + .map((row) => row.id) + .filter((rowId) => rowErrors[rowId]), + ); + + if (restoreRowIds.size === 0) { + return {}; + } + + return { + deletedExistingRows: state.deletedExistingRows.filter( + (row) => !restoreRowIds.has(row.id), + ), + rowErrors: { + ...state.rowErrors, + ...Object.fromEntries( + Array.from(restoreRowIds).map((rowId) => [ + rowId, + rowErrors[rowId], + ]), + ), + }, + }; + }), + materializeDraftRows: (results) => + set((state) => { + const rowErrors = { ...state.rowErrors }; + let edits = { ...state.edits }; + let draftRows = state.draftRows; + let materializedRows = state.materializedRows; + + for (const result of results) { + const draft = draftRows.find( + (row) => row.clientMutationId === result.clientMutationId, + ); + + if (!draft) { + continue; + } + + const message = getOperationErrorMessage(result.errors); + const nextRow = { + ...draft, + id: result.entityId, + kind: "existing" as const, + clientMutationId: undefined, + rowError: message, + }; + + delete rowErrors[draft.id]; + rowErrors[result.entityId] = message; + draftRows = draftRows.filter((row) => row.id !== draft.id); + materializedRows = [ + ...materializedRows.filter((row) => row.id !== result.entityId), + nextRow, + ]; + + if (edits[draft.id]) { + const { [draft.id]: draftEdits, ...restEdits } = edits; + edits = { + ...restEdits, + [result.entityId]: draftEdits, + }; + } + } + + return { + edits, + draftRows, + materializedRows, + rowErrors, + }; + }), + setRowErrors: (errors) => set({ rowErrors: errors }), // Save actions startSaving: () => set({ status: "saving" }), - onSaveSuccess: () => set({ status: "idle", edits: {} }), - onSaveError: () => set({ status: "idle" }), + onSaveSuccess: () => get().resetSession(), + onSaveError: () => set({ status: "error" }), // Column visibility actions setColumnVisibility: (field, visible) => @@ -162,13 +606,74 @@ export const useVariantsEditorStore = create()( }), // Selectors - hasChanges: () => Object.keys(get().edits).length > 0, + hasChanges: () => + Object.keys(get().edits).length > 0 || + get().draftRows.length > 0 || + get().materializedRows.length > 0 || + get().deletedExistingRows.length > 0, getChangesCount: () => { - return Object.values(get().edits).reduce( + const editCount = Object.values(get().edits).reduce( (count, rowEdits) => count + Object.keys(rowEdits).length, - 0 + 0, + ); + + return ( + editCount + + get().draftRows.length + + get().materializedRows.length + + get().deletedExistingRows.length ); }, + getCurrentRows: (baseRows) => { + const state = get(); + const hiddenRowIds = new Set([ + ...state.deletedExistingRows.map((row) => row.id), + ...state.committedDeletedRowIds, + ]); + const rows = baseRows + .filter((row) => !hiddenRowIds.has(row.id)) + .map((row) => + withRowError( + applyRowEdits(row, state.edits[row.id]), + state.rowErrors, + ), + ); + + const sessionRows = [ + ...state.materializedRows + .filter((row) => !hiddenRowIds.has(row.id)) + .map((row) => + withRowError( + applyRowEdits(row, state.edits[row.id]), + state.rowErrors, + ), + ), + ...state.draftRows.map((row) => + withRowError(row, state.rowErrors), + ), + ]; + + if (state.blankRow) { + sessionRows.push(withRowError(state.blankRow, state.rowErrors)); + } + + return [...rows, ...sessionRows]; + }, + getRowsForSave: (baseRows) => { + const rows = get().getCurrentRows(baseRows).filter( + (row) => row.kind !== "blank", + ); + + return { + existingRows: rows + .filter((row) => row.kind !== "draft") + .map(rowToSaveRow), + draftRows: rows + .filter((row) => row.kind === "draft") + .map(rowToSaveRow), + deletedRows: get().deletedExistingRows.map(rowToSaveRow), + }; + }, getRowEdits: (rowId) => get().edits[rowId], getFieldEdit: (rowId, field) => get().edits[rowId]?.[field], isColumnVisible: (field) => get().columnVisibility[field] ?? true, diff --git a/admin/src/domains/inventory/products/components/variants/index.ts b/admin/src/domains/inventory/products/components/variants/index.ts index f52c424d9..a79bc8f42 100644 --- a/admin/src/domains/inventory/products/components/variants/index.ts +++ b/admin/src/domains/inventory/products/components/variants/index.ts @@ -2,20 +2,6 @@ export { VariantsTable, extractOptionGroups, variantsToRowData, - type IVariantOption, - type IVariantRowBase, - type IOptionGroup, - type IVariantsTableProps, } from "./variants-table"; -export { - UnifiedVariantsTable, - getUnifiedDataForSave, - type IUnifiedVariantRow, - type IUnifiedVariant, - type IUnifiedVariantsTableProps, - type VariantTabKey, - type StockStatus, -} from "./unified-variants-table"; - export { EditVariantsModal } from "./edit-variants-modal"; diff --git a/admin/src/domains/inventory/products/components/variants/unified-variants-table.tsx b/admin/src/domains/inventory/products/components/variants/unified-variants-table.tsx deleted file mode 100644 index 63584747d..000000000 --- a/admin/src/domains/inventory/products/components/variants/unified-variants-table.tsx +++ /dev/null @@ -1,498 +0,0 @@ -"use client"; - -import { useMemo, useCallback, useState, useEffect, useRef } from "react"; -import { Typography, Image, Tag } from "antd"; -import { createStyles } from "antd-style"; -import { ColDef, CellValueChangedEvent } from "ag-grid-community"; -import type { CustomCellRendererProps } from "ag-grid-react"; -import { - VariantsTable, - IVariantRowBase, - IOptionGroup, - IVariantOption, - extractOptionGroups, - variantsToRowData, -} from "./variants-table"; -import { weightUnitOptions, dimensionUnitOptions } from "../../constants"; - -// ============================================================================ -// Types -// ============================================================================ - -export type VariantTabKey = "inventory" | "pricing" | "shipping" | "media" | "options"; - -export type StockStatus = "in_stock" | "low_stock" | "out_of_stock"; - -export interface IUnifiedVariantRow extends IVariantRowBase { - imageUrl: string | null; - // Inventory - sku: string | null; - stock: number; - stockStatus: StockStatus; - barcode: string | null; - // Pricing - price: number; - compareAtPrice: number | null; - costPrice: number | null; - // Shipping - weight: number | null; - weightUnit: string; - length: number | null; - width: number | null; - height: number | null; - dimensionUnit: string; -} - -export interface IUnifiedVariant { - id: string; - title: string; - imageUrl?: string | null; - // Inventory - sku?: string | null; - stock?: number; - barcode?: string | null; - // Pricing - price?: number; - compareAtPrice?: number | null; - costPrice?: number | null; - // Shipping - weight?: number | null; - weightUnit?: string; - length?: number | null; - width?: number | null; - height?: number | null; - dimensionUnit?: string; - // Options - options?: IVariantOption[]; -} - -export interface IUnifiedVariantsTableProps { - variants: IUnifiedVariant[]; - activeTab: VariantTabKey; - onChange?: (variants: IUnifiedVariantRow[]) => void; - formatPrice?: (amount: number) => string; - lowStockThreshold?: number; -} - -// ============================================================================ -// Styles -// ============================================================================ - -const useStyles = createStyles(({ token }) => ({ - imageCell: { - display: "flex", - alignItems: "center", - gap: 8, - }, - variantImage: { - borderRadius: 4, - objectFit: "cover" as const, - }, - imagePlaceholder: { - width: 40, - height: 40, - background: token.colorBgContainerDisabled, - borderRadius: 4, - flexShrink: 0, - }, - priceCell: { - display: "flex", - alignItems: "center", - height: "100%", - }, - stockTag: { - margin: 0, - }, -})); - -// ============================================================================ -// Constants -// ============================================================================ - -const NBSP = "\u00A0"; - -const defaultFormatPrice = (amount: number): string => { - const formatted = new Intl.NumberFormat("ru-RU", { - style: "currency", - currency: "RUB", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(amount / 100); - return formatted.replace(/\s+/g, NBSP); -}; - -const getStockStatus = (stock: number, threshold: number): StockStatus => { - if (stock <= 0) return "out_of_stock"; - if (stock <= threshold) return "low_stock"; - return "in_stock"; -}; - -// ============================================================================ -// Cell Renderers -// ============================================================================ - -const ImageCellRenderer = (props: CustomCellRendererProps) => { - const { styles } = useStyles(); - const { data } = props; - - if (!data) return null; - - return ( -
    - {data.imageUrl ? ( - {data.title} - ) : ( -
    - )} -
    - ); -}; - -const StockStatusCellRenderer = (props: CustomCellRendererProps) => { - const { styles } = useStyles(); - const { data } = props; - - if (!data) return null; - - const statusConfig: Record = { - in_stock: { color: "success", label: "In Stock" }, - low_stock: { color: "warning", label: "Low Stock" }, - out_of_stock: { color: "error", label: "Out of Stock" }, - }; - - const config = statusConfig[data.stockStatus]; - - return ( - - {config.label} - - ); -}; - -interface IPriceCellRendererProps extends CustomCellRendererProps { - formatPrice: (amount: number) => string; -} - -const PriceCellRenderer = (props: IPriceCellRendererProps) => { - const { styles } = useStyles(); - const { value, formatPrice } = props; - - if (value === null || value === undefined) { - return ( - - — - - ); - } - - return ( - - {formatPrice(value)} - - ); -}; - -// ============================================================================ -// Component -// ============================================================================ - -export const UnifiedVariantsTable = ({ - variants, - activeTab, - onChange, - formatPrice: formatPriceProp, - lowStockThreshold = 10, -}: IUnifiedVariantsTableProps) => { - const formatPrice = formatPriceProp || defaultFormatPrice; - - // Extract option groups - const optionGroups = useMemo( - () => extractOptionGroups(variants), - [variants] - ); - - // Transform to row data - const initialRowData = useMemo( - () => - variantsToRowData(variants, (v) => ({ - imageUrl: v.imageUrl ?? null, - // Inventory - sku: v.sku ?? null, - stock: v.stock ?? 0, - stockStatus: getStockStatus(v.stock ?? 0, lowStockThreshold), - barcode: v.barcode ?? null, - // Pricing - price: v.price ?? 0, - compareAtPrice: v.compareAtPrice ?? null, - costPrice: v.costPrice ?? null, - // Shipping - weight: v.weight ?? null, - weightUnit: v.weightUnit ?? "G", - length: v.length ?? null, - width: v.width ?? null, - height: v.height ?? null, - dimensionUnit: v.dimensionUnit ?? "CM", - })), - [variants, lowStockThreshold] - ); - - const [rowData, setRowData] = useState(initialRowData); - const rowDataRef = useRef(rowData); - - // Sync rowData when variants change - useEffect(() => { - setRowData(initialRowData); - }, [initialRowData]); - - useEffect(() => { - rowDataRef.current = rowData; - }, [rowData]); - - // Common image column - const imageColumn: ColDef = useMemo( - () => ({ - headerName: "Image", - field: "imageUrl", - width: 70, - cellRenderer: ImageCellRenderer, - sortable: false, - resizable: false, - }), - [] - ); - - // Column definitions for each tab - const inventoryColumns = useMemo[]>( - () => [ - { - headerName: "SKU", - field: "sku", - flex: 1, - minWidth: 120, - editable: true, - cellEditor: "agTextCellEditor", - valueFormatter: (params) => params.value ?? "—", - }, - { - headerName: "Stock", - field: "stock", - flex: 0.8, - minWidth: 100, - editable: true, - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 0 }, - }, - { - headerName: "Status", - field: "stockStatus", - flex: 1, - minWidth: 120, - cellRenderer: StockStatusCellRenderer, - }, - { - headerName: "Barcode", - field: "barcode", - flex: 1, - minWidth: 140, - editable: true, - cellEditor: "agTextCellEditor", - valueFormatter: (params) => params.value ?? "—", - }, - ], - [] - ); - - const pricingColumns = useMemo[]>( - () => [ - { - headerName: "Price", - field: "price", - flex: 1, - minWidth: 140, - editable: true, - cellRenderer: (params: CustomCellRendererProps) => ( - - ), - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 0 }, - valueFormatter: (params) => (params.value != null ? formatPrice(params.value) : "—"), - }, - { - headerName: "Compare at Price", - field: "compareAtPrice", - flex: 1, - minWidth: 160, - editable: true, - cellRenderer: (params: CustomCellRendererProps) => ( - - ), - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 0 }, - valueFormatter: (params) => (params.value != null ? formatPrice(params.value) : "—"), - }, - { - headerName: "Cost Price", - field: "costPrice", - flex: 1, - minWidth: 140, - editable: true, - cellRenderer: (params: CustomCellRendererProps) => ( - - ), - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 0 }, - valueFormatter: (params) => (params.value != null ? formatPrice(params.value) : "—"), - }, - ], - [formatPrice] - ); - - const shippingColumns = useMemo[]>( - () => [ - { - headerName: "Weight", - field: "weight", - flex: 0.8, - minWidth: 100, - editable: true, - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 2 }, - valueFormatter: (params) => { - if (params.value === null || params.value === undefined) return "—"; - const unit = params.data?.weightUnit || "G"; - const unitLabel = weightUnitOptions[unit as keyof typeof weightUnitOptions]?.label || unit; - return `${params.value} ${unitLabel}`; - }, - }, - { - headerName: "Length", - field: "length", - flex: 0.7, - minWidth: 90, - editable: true, - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 2 }, - valueFormatter: (params) => (params.value != null ? String(params.value) : "—"), - }, - { - headerName: "Width", - field: "width", - flex: 0.7, - minWidth: 90, - editable: true, - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 2 }, - valueFormatter: (params) => (params.value != null ? String(params.value) : "—"), - }, - { - headerName: "Height", - field: "height", - flex: 0.7, - minWidth: 90, - editable: true, - cellEditor: "agNumberCellEditor", - cellEditorParams: { min: 0, precision: 2 }, - valueFormatter: (params) => { - if (params.value === null || params.value === undefined) return "—"; - const unit = params.data?.dimensionUnit || "CM"; - const unitLabel = dimensionUnitOptions[unit as keyof typeof dimensionUnitOptions]?.label || unit; - return `${params.value} ${unitLabel}`; - }, - }, - ], - [] - ); - - const mediaColumns = useMemo[]>( - () => [imageColumn], - [imageColumn] - ); - - const optionsColumns = useMemo[]>( - () => [], - [] - ); - - // Get columns based on active tab - const additionalColumns = useMemo(() => { - switch (activeTab) { - case "inventory": - return inventoryColumns; - case "pricing": - return pricingColumns; - case "shipping": - return shippingColumns; - case "media": - return mediaColumns; - case "options": - return optionsColumns; - default: - return []; - } - }, [activeTab, inventoryColumns, pricingColumns, shippingColumns, mediaColumns, optionsColumns]); - - const handleCellValueChanged = useCallback( - (event: CellValueChangedEvent) => { - setRowData((prev) => { - const updated = prev.map((row) => { - if (row.id !== event.data?.id) return row; - - const updatedRow = { ...row, ...event.data }; - - // Recalculate stock status when stock changes - if (event.colDef?.field === "stock") { - updatedRow.stockStatus = getStockStatus(updatedRow.stock, lowStockThreshold); - } - - return updatedRow; - }); - onChange?.(updated); - return updated; - }); - }, - [onChange, lowStockThreshold] - ); - - return ( - - rowData={rowData} - optionGroups={optionGroups} - additionalColumns={additionalColumns} - onCellValueChanged={handleCellValueChanged} - pinnedTitle={activeTab !== "options"} - showOptions={activeTab === "options"} - /> - ); -}; - -// ============================================================================ -// Export data getters for save functionality -// ============================================================================ - -export function getUnifiedDataForSave(rows: IUnifiedVariantRow[]) { - return rows.map((row) => ({ - id: row.id, - // Inventory - sku: row.sku, - stock: row.stock, - barcode: row.barcode, - // Pricing - price: row.price, - compareAtPrice: row.compareAtPrice, - costPrice: row.costPrice, - // Shipping - weight: row.weight, - weightUnit: row.weightUnit, - length: row.length, - width: row.width, - height: row.height, - dimensionUnit: row.dimensionUnit, - })); -} diff --git a/admin/src/domains/inventory/products/components/variants/variants-table.tsx b/admin/src/domains/inventory/products/components/variants/variants-table.tsx index 9f4cd0ab1..90fcc7aca 100644 --- a/admin/src/domains/inventory/products/components/variants/variants-table.tsx +++ b/admin/src/domains/inventory/products/components/variants/variants-table.tsx @@ -11,6 +11,7 @@ import { GetRowIdParams, } from "ag-grid-community"; import { useAgGridTheme } from "@/hooks"; +import { Dash } from "@/shared/components/editor-grid"; ModuleRegistry.registerModules([AllCommunityModule]); @@ -72,6 +73,14 @@ const useStyles = createStyles(({ token }) => ({ opacity: 1, }, }, + "& .ec-dash": { + display: "inline-block", + width: 24, + height: 4, + backgroundColor: token.colorBorder, + borderRadius: 2, + verticalAlign: "middle", + }, }, })); @@ -110,14 +119,16 @@ export function VariantsTable({ cols.push({ headerName: group.title, valueGetter: (params) => - (params.data as IVariantRowBase)?.options[group.slug] || "—", + (params.data as IVariantRowBase)?.options[group.slug] || null, + cellRenderer: ({ value }: { value?: unknown }) => + value == null ? : {String(value)}, flex: 1, minWidth: 120, }); }); } - // Add additional columns (pricing, inventory, etc.) + // Add additional columns such as pricing or shipping attributes. cols.push(...additionalColumns); // Make the last column non-resizable diff --git a/admin/src/domains/inventory/products/constants.ts b/admin/src/domains/inventory/products/constants.ts index 0c80966a6..70fbbf4c9 100644 --- a/admin/src/domains/inventory/products/constants.ts +++ b/admin/src/domains/inventory/products/constants.ts @@ -1,39 +1,39 @@ -import { WeightUnit, DimensionUnit } from "@/mocks/products/types"; +import { DimensionUnit, WeightUnit } from "@/graphql/types"; export const weightUnitOptions = { [WeightUnit.G]: { key: WeightUnit.G, label: 'g', }, - [WeightUnit.KG]: { - key: WeightUnit.KG, + [WeightUnit.Kg]: { + key: WeightUnit.Kg, label: 'kg', }, - [WeightUnit.OZ]: { - key: WeightUnit.OZ, + [WeightUnit.Oz]: { + key: WeightUnit.Oz, label: 'oz', }, - [WeightUnit.LB]: { - key: WeightUnit.LB, + [WeightUnit.Lb]: { + key: WeightUnit.Lb, label: 'lb', }, }; export const dimensionUnitOptions = { - [DimensionUnit.MM]: { - key: DimensionUnit.MM, + [DimensionUnit.Mm]: { + key: DimensionUnit.Mm, label: 'mm', }, - [DimensionUnit.CM]: { - key: DimensionUnit.CM, + [DimensionUnit.Cm]: { + key: DimensionUnit.Cm, label: 'cm', }, [DimensionUnit.M]: { key: DimensionUnit.M, label: 'm', }, - [DimensionUnit.IN]: { - key: DimensionUnit.IN, + [DimensionUnit.In]: { + key: DimensionUnit.In, label: 'in', }, }; diff --git a/admin/src/domains/inventory/products/graphql/fragments.ts b/admin/src/domains/inventory/products/graphql/fragments.ts new file mode 100644 index 000000000..4d76146f7 --- /dev/null +++ b/admin/src/domains/inventory/products/graphql/fragments.ts @@ -0,0 +1,343 @@ +import { gql } from "@apollo/client"; +import { + FILE_FRAGMENT, + RICH_TEXT_FRAGMENT, + USER_ERROR_FRAGMENT, +} from "../../graphql/shared-fragments"; + +export { + FILE_FRAGMENT, + RICH_TEXT_FRAGMENT, + USER_ERROR_FRAGMENT, +} from "../../graphql/shared-fragments"; + +export const PRODUCT_MEDIA_ITEM_FRAGMENT = gql` + fragment ProductMediaItemFields on ProductMediaItem { + sortIndex + file { + ...FileFields + } + } + ${FILE_FRAGMENT} +`; + +export const VARIANT_MEDIA_ITEM_FRAGMENT = gql` + fragment VariantMediaItemFields on VariantMediaItem { + sortIndex + file { + ...FileFields + } + } + ${FILE_FRAGMENT} +`; + +export const SELECTED_OPTION_FRAGMENT = gql` + fragment SelectedOptionFields on SelectedOption { + optionId + optionValueId + } +`; + +export const INVENTORY_ITEM_FRAGMENT = gql` + fragment InventoryItemFields on InventoryItem { + id + variantId + sku + trackInventory + continueSellingWhenOutOfStock + totalAvailable + createdAt + updatedAt + stock { + id + warehouseId + variantId + quantityOnHand + reservedQuantity + unavailableQuantity + availableForSale + createdAt + updatedAt + } + unitCost { + amountMinor + currency + effectiveFrom + } + } +`; + +export const VARIANT_FRAGMENT = gql` + fragment VariantFields on Variant { + id + handle + title + isDefault + createdAt + updatedAt + deletedAt + externalId + externalSystem + selectedOptions { + ...SelectedOptionFields + } + media { + ...VariantMediaItemFields + } + price { + id + amountMinor + compareAtMinor + currency + effectiveFrom + effectiveTo + isCurrent + recordedAt + } + weight { + value + } + dimensions { + length + width + height + } + inventoryItem { + ...InventoryItemFields + } + } + ${SELECTED_OPTION_FRAGMENT} + ${VARIANT_MEDIA_ITEM_FRAGMENT} + ${INVENTORY_ITEM_FRAGMENT} +`; + +export const PRODUCT_OPTION_VALUE_FRAGMENT = gql` + fragment ProductOptionValueFields on ProductOptionValue { + id + name + slug + sortIndex + swatch { + id + swatchType + colorOne + colorTwo + metadata + file { + ...FileFields + } + } + } + ${FILE_FRAGMENT} +`; + +export const PRODUCT_OPTION_FRAGMENT = gql` + fragment ProductOptionFields on ProductOption { + id + name + slug + displayType + sortIndex + values { + ...ProductOptionValueFields + } + } + ${PRODUCT_OPTION_VALUE_FRAGMENT} +`; + +export const PRODUCT_CATEGORY_FRAGMENT = gql` + fragment ProductCategoryFields on Category { + id + name + handle + isPublished + productsCount + media { + sortIndex + file { + ...FileFields + } + } + } + ${FILE_FRAGMENT} +`; + +export const PRODUCT_TAG_FRAGMENT = gql` + fragment ProductTagFields on Tag { + id + name + handle + productsCount + } +`; + +export const VENDOR_FRAGMENT = gql` + fragment VendorFields on Vendor { + id + name + } +`; + +export const PRODUCT_FEATURE_FRAGMENT = gql` + fragment ProductFeatureFields on ProductFeature { + id + name + slug + isGroup + index + values { + id + name + slug + index + } + } +`; + +export const PRODUCT_LIST_FRAGMENT = gql` + fragment ProductListFields on Product { + id + title + handle + isPublished + media { + sortIndex + file { + url + originalName + altText + } + } + primaryCategory { + id + name + } + vendor { + ...VendorFields + } + priceRange { + minPriceAmount + maxPriceAmount + currency + } + variants(first: 100) { + edges { + node { + id + inventoryItem { + totalAvailable + } + } + } + } + } + ${VENDOR_FRAGMENT} +`; + +export const PRODUCT_EDITOR_BASE_FRAGMENT = gql` + fragment ProductEditorBaseFields on Product { + id + title + handle + isPublished + publishedAt + createdAt + updatedAt + deletedAt + revision + variantsCount + description { + ...RichTextFields + } + excerpt { + ...RichTextFields + } + seo { + seoTitle + seoDescription + ogTitle + ogDescription + ogImage { + ...FileFields + } + } + media { + ...ProductMediaItemFields + } + primaryCategory { + ...ProductCategoryFields + } + categoryAssignments { + isPrimary + category { + ...ProductCategoryFields + } + } + tags { + ...ProductTagFields + } + features { + ...ProductFeatureFields + } + options { + ...ProductOptionFields + } + } + ${RICH_TEXT_FRAGMENT} + ${FILE_FRAGMENT} + ${PRODUCT_MEDIA_ITEM_FRAGMENT} + ${PRODUCT_CATEGORY_FRAGMENT} + ${PRODUCT_TAG_FRAGMENT} + ${PRODUCT_FEATURE_FRAGMENT} + ${PRODUCT_OPTION_FRAGMENT} +`; + +export const PRODUCT_DETAILS_FRAGMENT = gql` + fragment ProductDetailsFields on Product { + ...ProductEditorBaseFields + variants(first: $variantsFirst, after: $variantsAfter) { + edges { + cursor + node { + ...VariantFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + ${PRODUCT_EDITOR_BASE_FRAGMENT} + ${VARIANT_FRAGMENT} +`; + +export const PRODUCT_MUTATION_RESULT_FRAGMENT = gql` + fragment ProductMutationResultFields on Product { + ...ProductEditorBaseFields + variants(first: 100) { + edges { + cursor + node { + ...VariantFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + ${PRODUCT_EDITOR_BASE_FRAGMENT} + ${VARIANT_FRAGMENT} +`; + +export const VARIANT_BASIC_FRAGMENT = VARIANT_FRAGMENT; +export const PRODUCT_BASIC_FRAGMENT = PRODUCT_LIST_FRAGMENT; +export const PRODUCT_FRAGMENT = PRODUCT_MUTATION_RESULT_FRAGMENT; diff --git a/admin/src/domains/inventory/products/graphql/index.ts b/admin/src/domains/inventory/products/graphql/index.ts new file mode 100644 index 000000000..176b6f88a --- /dev/null +++ b/admin/src/domains/inventory/products/graphql/index.ts @@ -0,0 +1,3 @@ +export * from "./fragments"; +export * from "./queries"; +export * from "./mutations"; diff --git a/admin/src/domains/inventory/products/graphql/mutations.ts b/admin/src/domains/inventory/products/graphql/mutations.ts new file mode 100644 index 000000000..572f97be2 --- /dev/null +++ b/admin/src/domains/inventory/products/graphql/mutations.ts @@ -0,0 +1,68 @@ +import { gql } from "@apollo/client"; +import { USER_ERROR_FRAGMENT } from "../../graphql/shared-fragments"; +import { PRODUCT_MUTATION_RESULT_FRAGMENT } from "./fragments"; + +export const PRODUCT_CREATE_MUTATION = gql` + mutation ProductCreate($input: ProductCreateInput!) { + catalogMutation { + productCreate(input: $input) { + product { + ...ProductMutationResultFields + } + userErrors { + ...UserErrorFields + } + } + } + } + ${PRODUCT_MUTATION_RESULT_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const PRODUCT_UPDATE_MUTATION = gql` + mutation ProductUpdate( + $productId: ID! + $operations: ProductUpdateInput + $expectedRevision: Int + ) { + catalogMutation { + productUpdate( + productId: $productId + operations: $operations + expectedRevision: $expectedRevision + ) { + product { + ...ProductMutationResultFields + } + operationResults { + applied + type + clientMutationId + entityId + errors { + ...UserErrorFields + } + } + userErrors { + ...UserErrorFields + } + } + } + } + ${PRODUCT_MUTATION_RESULT_FRAGMENT} + ${USER_ERROR_FRAGMENT} +`; + +export const PRODUCT_DELETE_MUTATION = gql` + mutation ProductDelete($input: ProductDeleteInput!) { + catalogMutation { + productDelete(input: $input) { + deletedProductId + userErrors { + ...UserErrorFields + } + } + } + } + ${USER_ERROR_FRAGMENT} +`; diff --git a/admin/src/domains/inventory/products/graphql/operation-types.ts b/admin/src/domains/inventory/products/graphql/operation-types.ts new file mode 100644 index 000000000..f0e9d8eaf --- /dev/null +++ b/admin/src/domains/inventory/products/graphql/operation-types.ts @@ -0,0 +1,207 @@ +import type { + ApiCatalogMutation, + ApiCatalogQuery, + ApiGenericUserError, + ApiInventoryItem, + ApiInventoryQuery, + ApiOperationResult, + ApiPricingWidgetInput, + ApiPricingWidgetPayload, + ApiProduct, + ApiProductConnection, + ApiProductCreateInput, + ApiProductDeleteInput, + ApiProductFeature, + ApiProductInventoryWidget, + ApiProductOption, + ApiProductOrderByInput, + ApiProductProductsMetaInput, + ApiProductUpdateInput, + ApiProductWhereInput, + ApiVendorConnection, + ApiVendorOrderByInput, + ApiVendorWhereInput, + ApiVariantConnection, + ApiVariantOrderByInput, + ApiVariantWhereInput, + ApiWarehouseAssignableVariantOrderByInput, + ApiWarehouseAssignableVariantWhereInput, + ApiWarehouseConnection, + ApiWidgetQuery, +} from "@/graphql/types"; + +export interface ProductsQueryData { + catalogQuery: Pick & { + products: ApiProductConnection; + }; +} + +export interface ProductsQueryVariables { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: ApiProductWhereInput | null; + orderBy?: ApiProductOrderByInput[] | null; + meta?: ApiProductProductsMetaInput | null; +} + +export interface VendorsQueryData { + catalogQuery: Pick & { + vendors: ApiVendorConnection; + }; +} + +export interface VendorsQueryVariables { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: ApiVendorWhereInput | null; + orderBy?: ApiVendorOrderByInput[] | null; +} + +export interface ProductDetailsQueryData { + catalogQuery: Pick & { + product: ApiProduct | null; + }; +} + +export interface ProductDetailsQueryVariables { + id: string; + variantsFirst?: number; + variantsAfter?: string | null; +} + +export interface ProductVariantsQueryData { + catalogQuery: Pick & { + product: (Pick & { + variants: ApiVariantConnection; + }) | null; + }; +} + +export interface ProductVariantsQueryVariables { + id: string; + first?: number; + after?: string | null; +} + +export interface VariantsQueryData { + catalogQuery: Pick & { + variants: ApiVariantConnection; + }; +} + +export interface VariantsQueryVariables { + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: ApiVariantWhereInput | null; + orderBy?: ApiVariantOrderByInput[] | null; +} + +export interface WarehouseAssignableVariantsQueryData { + inventoryQuery: Pick & { + warehouseAssignableVariants: ApiVariantConnection; + }; +} + +export interface WarehouseAssignableVariantsQueryVariables { + warehouseId: string; + first?: number; + after?: string | null; + last?: number; + before?: string | null; + where?: ApiWarehouseAssignableVariantWhereInput | null; + orderBy?: ApiWarehouseAssignableVariantOrderByInput[] | null; +} + +export interface ProductPricingWidgetQueryData { + widgetQuery: Pick & { + pricing: ApiPricingWidgetPayload; + }; +} + +export interface ProductPricingWidgetQueryVariables { + input: ApiPricingWidgetInput; +} + +export interface ProductInventoryWidgetQueryData { + widgetQuery: Pick & { + inventory: ApiProductInventoryWidget | null; + }; +} + +export interface ProductInventoryWidgetQueryVariables { + productId: string; +} + +export interface InventoryDefaultWarehouseQueryData { + inventoryQuery: Pick & { + warehouses: ApiWarehouseConnection; + }; +} + +export type InventoryDefaultWarehouseQueryVariables = Record; + +export interface InventoryItemByVariantQueryData { + inventoryQuery: Pick & { + inventoryItemByVariant: ApiInventoryItem | null; + }; +} + +export interface InventoryItemByVariantQueryVariables { + variantId: string; +} + +export interface ProductCreateMutationData { + catalogMutation: Pick & { + productCreate: { + product: ApiProduct | null; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface ProductCreateMutationVariables { + input: ApiProductCreateInput; +} + +export interface ProductUpdateMutationData { + catalogMutation: Pick & { + productUpdate: { + product: ApiProduct | null; + operationResults: ApiOperationResult[]; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface ProductUpdateMutationVariables { + productId: string; + operations?: ApiProductUpdateInput | null; + expectedRevision?: number | null; +} + +export interface ProductDeleteMutationData { + catalogMutation: Pick & { + productDelete: { + deletedProductId: string | null; + userErrors: ApiGenericUserError[]; + }; + }; +} + +export interface ProductDeleteMutationVariables { + input: ApiProductDeleteInput; +} + +export type ProductFeaturesSyncProduct = Pick & { + features: ApiProductFeature[]; +}; + +export type ProductOptionsSyncProduct = Pick & { + options: ApiProductOption[]; +}; diff --git a/admin/src/domains/inventory/products/graphql/queries.ts b/admin/src/domains/inventory/products/graphql/queries.ts new file mode 100644 index 000000000..05d358dbc --- /dev/null +++ b/admin/src/domains/inventory/products/graphql/queries.ts @@ -0,0 +1,351 @@ +import { gql } from "@apollo/client"; +import { + INVENTORY_ITEM_FRAGMENT, + PRODUCT_DETAILS_FRAGMENT, + PRODUCT_LIST_FRAGMENT, + PRODUCT_MEDIA_ITEM_FRAGMENT, + VARIANT_FRAGMENT, +} from "./fragments"; + +export const PRODUCTS_QUERY = gql` + query Products( + $first: Int + $after: String + $last: Int + $before: String + $where: ProductWhereInput + $orderBy: [ProductOrderByInput!] + $meta: ProductProductsMetaInput + ) { + catalogQuery { + products( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + meta: $meta + ) { + edges { + cursor + node { + ...ProductListFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + ${PRODUCT_LIST_FRAGMENT} +`; + +export const VENDORS_QUERY = gql` + query Vendors( + $first: Int + $after: String + $last: Int + $before: String + $where: VendorWhereInput + $orderBy: [VendorOrderByInput!] + ) { + catalogQuery { + vendors( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + id + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } +`; + +export const PRODUCT_DETAILS_QUERY = gql` + query ProductDetails( + $id: ID! + $variantsFirst: Int + $variantsAfter: String + ) { + catalogQuery { + product(id: $id) { + ...ProductDetailsFields + } + } + } + ${PRODUCT_DETAILS_FRAGMENT} +`; + +export const PRODUCT_VARIANTS_QUERY = gql` + query ProductVariants($id: ID!, $first: Int, $after: String) { + catalogQuery { + product(id: $id) { + id + variants(first: $first, after: $after) { + edges { + cursor + node { + ...VariantFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + } + ${VARIANT_FRAGMENT} +`; + +export const VARIANTS_QUERY = gql` + query Variants( + $first: Int + $after: String + $last: Int + $before: String + $where: VariantWhereInput + $orderBy: [VariantOrderByInput!] + ) { + catalogQuery { + variants( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...VariantFields + product { + id + title + handle + revision + media { + ...ProductMediaItemFields + } + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + ${VARIANT_FRAGMENT} + ${PRODUCT_MEDIA_ITEM_FRAGMENT} +`; + +export const WAREHOUSE_ASSIGNABLE_VARIANTS_QUERY = gql` + query WarehouseAssignableVariants( + $warehouseId: ID! + $first: Int + $after: String + $last: Int + $before: String + $where: WarehouseAssignableVariantWhereInput + $orderBy: [WarehouseAssignableVariantOrderByInput!] + ) { + inventoryQuery { + warehouseAssignableVariants( + warehouseId: $warehouseId + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: $orderBy + ) { + edges { + cursor + node { + ...VariantFields + product { + id + title + handle + revision + media { + ...ProductMediaItemFields + } + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } + ${VARIANT_FRAGMENT} + ${PRODUCT_MEDIA_ITEM_FRAGMENT} +`; + +export const PRODUCT_PRICING_WIDGET_QUERY = gql` + query ProductPricingWidget($input: PricingWidgetInput!) { + widgetQuery { + pricing(input: $input) { + currentPrice { + id + amountMinor + compareAtMinor + currency + effectiveFrom + effectiveTo + isCurrent + recordedAt + } + currentCostPrice { + id + unitCostMinor + currency + effectiveFrom + effectiveTo + isCurrent + recordedAt + } + history { + edges { + cursor + node { + id + amountMinor + compareAtMinor + currency + effectiveFrom + effectiveTo + isCurrent + recordedAt + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + statistics { + minPriceMinor + maxPriceMinor + avgPriceMinor + currency + } + } + } + } +`; + +export const PRODUCT_INVENTORY_WIDGET_QUERY = gql` + query ProductInventoryWidget($productId: ID!) { + widgetQuery { + inventory(productId: $productId) { + quantities { + availableForSale + onHand + reserved + unavailable + } + availableChange7d + skuStatus { + total + lowStock { + count + averageDays + } + outOfStock { + count + averageDays + } + backorder { + count + averageDays + } + } + backorder { + quantity + etaAvgDays + } + alertThreshold { + method + minimumStock + } + } + } + } +`; + +export const INVENTORY_DEFAULT_WAREHOUSE_QUERY = gql` + query InventoryDefaultWarehouse { + inventoryQuery { + warehouses(first: 1, where: { isDefault: { _eq: true } }) { + edges { + node { + id + code + name + isDefault + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + } +`; + +export const INVENTORY_ITEM_BY_VARIANT_QUERY = gql` + query InventoryItemByVariant($variantId: ID!) { + inventoryQuery { + inventoryItemByVariant(variantId: $variantId) { + ...InventoryItemFields + } + } + } + ${INVENTORY_ITEM_FRAGMENT} +`; diff --git a/admin/src/domains/inventory/products/hooks/index.ts b/admin/src/domains/inventory/products/hooks/index.ts index fb0d65035..599300855 100644 --- a/admin/src/domains/inventory/products/hooks/index.ts +++ b/admin/src/domains/inventory/products/hooks/index.ts @@ -1,2 +1,13 @@ export * from "./use-products"; +export * from "./use-variants"; +export * from "./use-vendors"; +export * from "./use-product"; +export * from "./use-product-pricing-widget"; export * from "./use-create-product"; +export * from "./use-update-product"; +export * from "./use-update-product-status"; +export * from "./use-delete-product"; +export * from "./use-sync-product-features"; +export * from "./use-sync-product-options"; +export * from "./use-product-variants-connection"; +export * from "./use-product-variants-loader"; diff --git a/admin/src/domains/inventory/products/hooks/use-create-product.ts b/admin/src/domains/inventory/products/hooks/use-create-product.ts index ed2c2c7c3..81b959310 100644 --- a/admin/src/domains/inventory/products/hooks/use-create-product.ts +++ b/admin/src/domains/inventory/products/hooks/use-create-product.ts @@ -1,13 +1,17 @@ "use client"; import { useMutation } from "@apollo/client/react"; -import { useCallback, useState } from "react"; -import { PRODUCT_CREATE_MUTATION } from "../../graphql"; -import type { ApiProduct, ApiGenericUserError, ApiProductCreateInput } from "@/graphql/types"; +import { useCallback } from "react"; +import { PRODUCT_CREATE_MUTATION, PRODUCTS_QUERY } from "../graphql"; +import type { ApiGenericUserError, ApiProduct } from "@/graphql/types"; import { prepareProductPayload, type CreateProductInput, -} from "../modals/create-product-modal/utils/prepare-product-payload"; +} from "../mappers"; +import type { + ProductCreateMutationData, + ProductCreateMutationVariables, +} from "../graphql/operation-types"; export type { CreateProductInput }; @@ -20,6 +24,7 @@ interface UseCreateProductReturn { createProduct: (input: CreateProductInput) => Promise; loading: boolean; error: Error | null; + reset: () => void; } // ============================================ @@ -56,26 +61,13 @@ interface UseCreateProductReturn { * ``` */ export function useCreateProduct(): UseCreateProductReturn { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const [createProductMutation] = useMutation< - { - inventoryMutation: { - productCreate: { - product: ApiProduct | null; - userErrors: ApiGenericUserError[]; - }; - }; - }, - { input: ApiProductCreateInput } + const [createProductMutation, { loading, error, reset }] = useMutation< + ProductCreateMutationData, + ProductCreateMutationVariables >(PRODUCT_CREATE_MUTATION); const createProduct = useCallback( async (input: CreateProductInput): Promise => { - setLoading(true); - setError(null); - try { const payload = prepareProductPayload(input); @@ -83,10 +75,17 @@ export function useCreateProduct(): UseCreateProductReturn { variables: { input: payload, }, + refetchQueries: [ + { + query: PRODUCTS_QUERY, + variables: { first: 20, after: null, last: null, before: null }, + }, + ], + awaitRefetchQueries: true, }); const createPayload = - createResult.data?.inventoryMutation.productCreate; + createResult.data?.catalogMutation.productCreate; if (createPayload?.userErrors && createPayload.userErrors.length > 0) { return { @@ -102,14 +101,11 @@ export function useCreateProduct(): UseCreateProductReturn { } catch (err) { const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred"; - setError(err instanceof Error ? err : new Error(errorMessage)); return { product: null, userErrors: [{ message: errorMessage, code: "UNEXPECTED_ERROR" }], }; - } finally { - setLoading(false); } }, [createProductMutation] @@ -118,6 +114,7 @@ export function useCreateProduct(): UseCreateProductReturn { return { createProduct, loading, - error, + error: error ?? null, + reset, }; } diff --git a/admin/src/domains/inventory/products/hooks/use-default-warehouse.ts b/admin/src/domains/inventory/products/hooks/use-default-warehouse.ts new file mode 100644 index 000000000..da8b5f413 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-default-warehouse.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useQuery } from "@apollo/client/react"; +import type { ApiWarehouse } from "@/graphql/types"; +import { INVENTORY_DEFAULT_WAREHOUSE_QUERY } from "../graphql"; +import type { + InventoryDefaultWarehouseQueryData, + InventoryDefaultWarehouseQueryVariables, +} from "../graphql/operation-types"; + +export interface UseDefaultWarehouseReturn { + defaultWarehouse: ApiWarehouse | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useDefaultWarehouse(): UseDefaultWarehouseReturn { + const { data, loading, error, refetch: refetchDefaultWarehouse } = useQuery< + InventoryDefaultWarehouseQueryData, + InventoryDefaultWarehouseQueryVariables + >(INVENTORY_DEFAULT_WAREHOUSE_QUERY, { + fetchPolicy: "cache-and-network", + }); + + const defaultWarehouse = + data?.inventoryQuery.warehouses.edges[0]?.node ?? null; + + const refetch = useCallback(async () => { + const result = await refetchDefaultWarehouse(); + + return result.data?.inventoryQuery.warehouses.edges[0]?.node ?? null; + }, [refetchDefaultWarehouse]); + + return useMemo( + () => ({ + defaultWarehouse, + loading, + error: error ?? null, + refetch, + }), + [defaultWarehouse, error, loading, refetch], + ); +} diff --git a/admin/src/domains/inventory/products/hooks/use-delete-product.ts b/admin/src/domains/inventory/products/hooks/use-delete-product.ts new file mode 100644 index 000000000..5878ef184 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-delete-product.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { ApiGenericUserError, ApiProductDeleteInput } from "@/graphql/types"; +import { PRODUCT_DELETE_MUTATION, PRODUCTS_QUERY } from "../graphql"; +import type { + ProductDeleteMutationData, + ProductDeleteMutationVariables, +} from "../graphql/operation-types"; + +interface DeleteProductResult { + deletedProductId: string | null; + userErrors: ApiGenericUserError[]; +} + +interface UseDeleteProductReturn { + deleteProduct: (input: ApiProductDeleteInput) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useDeleteProduct(): UseDeleteProductReturn { + const [deleteProductMutation, { loading, error, reset }] = useMutation< + ProductDeleteMutationData, + ProductDeleteMutationVariables + >(PRODUCT_DELETE_MUTATION); + + const deleteProduct = useCallback( + async (input: ApiProductDeleteInput): Promise => { + try { + const result = await deleteProductMutation({ + variables: { input }, + refetchQueries: [ + { + query: PRODUCTS_QUERY, + variables: { first: 20, after: null, last: null, before: null }, + }, + ], + }); + + const payload = result.data?.catalogMutation.productDelete; + + return { + deletedProductId: payload?.deletedProductId ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + + return { + deletedProductId: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [deleteProductMutation], + ); + + return { + deleteProduct, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-ensure-variant-inventory-items.ts b/admin/src/domains/inventory/products/hooks/use-ensure-variant-inventory-items.ts new file mode 100644 index 000000000..cadb4eb48 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-ensure-variant-inventory-items.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useApolloClient } from "@apollo/client/react"; +import type { ApiVariant } from "@/graphql/types"; +import { INVENTORY_ITEM_BY_VARIANT_QUERY } from "../graphql"; +import type { + InventoryItemByVariantQueryData, + InventoryItemByVariantQueryVariables, +} from "../graphql/operation-types"; + +interface UseEnsureVariantInventoryItemsReturn { + ensureVariantInventoryItems: (variants: ApiVariant[]) => Promise; + loading: boolean; +} + +export function useEnsureVariantInventoryItems(): UseEnsureVariantInventoryItemsReturn { + const client = useApolloClient(); + const [loading, setLoading] = useState(false); + + const ensureVariantInventoryItems = useCallback( + async (variants: ApiVariant[]): Promise => { + const missingVariants = variants.filter((variant) => !variant.inventoryItem); + + if (missingVariants.length === 0) { + return variants; + } + + setLoading(true); + + try { + const inventoryItemsByVariantId = new Map(); + + for (const variant of missingVariants) { + const result = await client.query< + InventoryItemByVariantQueryData, + InventoryItemByVariantQueryVariables + >({ + query: INVENTORY_ITEM_BY_VARIANT_QUERY, + variables: { variantId: variant.id }, + fetchPolicy: "network-only", + }); + + const inventoryItem = + result.data?.inventoryQuery.inventoryItemByVariant ?? null; + + if (!inventoryItem) { + throw new Error( + `Inventory item could not be loaded for variant ${variant.title ?? variant.handle}`, + ); + } + + inventoryItemsByVariantId.set(variant.id, inventoryItem); + } + + return variants.map((variant) => { + const inventoryItem = inventoryItemsByVariantId.get(variant.id); + + return inventoryItem ? { ...variant, inventoryItem } : variant; + }); + } finally { + setLoading(false); + } + }, + [client], + ); + + return { + ensureVariantInventoryItems, + loading, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-product-inventory-widget.ts b/admin/src/domains/inventory/products/hooks/use-product-inventory-widget.ts new file mode 100644 index 000000000..9622bd681 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-product-inventory-widget.ts @@ -0,0 +1,71 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { skipToken, useQuery } from "@apollo/client/react"; +import type { ApiProductInventoryWidget } from "@/graphql/types"; +import { PRODUCT_INVENTORY_WIDGET_QUERY } from "../graphql"; +import type { + ProductInventoryWidgetQueryData, + ProductInventoryWidgetQueryVariables, +} from "../graphql/operation-types"; + +export interface UseProductInventoryWidgetOptions { + productId: string | null; + skip?: boolean; +} + +export interface UseProductInventoryWidgetReturn { + data: ApiProductInventoryWidget | null; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useProductInventoryWidget({ + productId, + skip = false, +}: UseProductInventoryWidgetOptions): UseProductInventoryWidgetReturn { + const queryOptions = useMemo( + () => + productId && !skip + ? { + variables: { productId }, + fetchPolicy: "cache-and-network" as const, + } + : skipToken, + [productId, skip], + ); + + const { + data, + previousData, + loading: isLoading, + error, + refetch: refetchInventoryWidget, + } = useQuery< + ProductInventoryWidgetQueryData, + ProductInventoryWidgetQueryVariables + >(PRODUCT_INVENTORY_WIDGET_QUERY, queryOptions); + + const refetch = useCallback(async () => { + if (!productId || skip) { + return null; + } + + const result = await refetchInventoryWidget({ productId }); + + return result.data?.widgetQuery.inventory ?? null; + }, [productId, refetchInventoryWidget, skip]); + + const effectiveData = data ?? previousData; + + return useMemo( + () => ({ + data: effectiveData?.widgetQuery.inventory ?? null, + isLoading, + error: error ?? null, + refetch, + }), + [effectiveData, error, isLoading, refetch], + ); +} diff --git a/admin/src/domains/inventory/products/hooks/use-product-pricing-widget.ts b/admin/src/domains/inventory/products/hooks/use-product-pricing-widget.ts new file mode 100644 index 000000000..f42e00c2c --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-product-pricing-widget.ts @@ -0,0 +1,203 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { skipToken, useQuery } from "@apollo/client/react"; +import type { + ApiPricingWidgetInput, + ApiPricingWidgetPayload, + ApiVariantConnection, + CurrencyCode, +} from "@/graphql/types"; +import { useDefaultCurrency } from "@/domains/workspace"; +import { PRODUCT_PRICING_WIDGET_QUERY } from "../graphql"; +import type { + ProductPricingWidgetQueryData, + ProductPricingWidgetQueryVariables, +} from "../graphql/operation-types"; +import { + useProductVariantsConnection, + type UseProductVariantsConnectionReturn, +} from "./use-product-variants-connection"; +import { getDateRangeForPeriod, type Period } from "../utils/periods"; + +export interface UseProductPricingWidgetOptions { + productId: string | null; + initialPeriod?: T; + defaultCurrency?: CurrencyCode | null; +} + +export interface UseProductPricingWidgetReturn { + data: ApiPricingWidgetPayload | null; + isLoading: boolean; + error: Error | null; + variants: ApiVariantConnection; + isLoadingVariants: boolean; + loadMoreVariants: UseProductVariantsConnectionReturn["loadMore"]; + selectedVariantId: string | null; + selectVariant: (id: string) => void; + period: T; + setPeriod: (period: T) => void; + refetch: () => Promise; + refetchVariants: UseProductVariantsConnectionReturn["refetch"]; +} + +const VARIANTS_PAGE_SIZE = 20; +const PRICE_HISTORY_PAGE_SIZE = 100; + +function buildPricingInput( + variantId: string | null, + currency: CurrencyCode | null | undefined, + defaultCurrency: CurrencyCode | null | undefined, + period: string, +): ApiPricingWidgetInput | null { + if (!variantId) { + return null; + } + + const resolvedCurrency = currency ?? defaultCurrency; + + if (!resolvedCurrency) { + return null; + } + + const range = getDateRangeForPeriod(period as Period); + + return { + variantId, + currency: resolvedCurrency, + from: range.gte, + to: range.lt, + first: PRICE_HISTORY_PAGE_SIZE, + }; +} + +export function useProductPricingWidget({ + productId, + initialPeriod = "30d" as T, + defaultCurrency, +}: UseProductPricingWidgetOptions): UseProductPricingWidgetReturn { + const storeDefaultCurrency = useDefaultCurrency(); + const fallbackCurrency = defaultCurrency ?? storeDefaultCurrency; + const [selectedVariantId, setSelectedVariantId] = useState( + null, + ); + const [period, setPeriod] = useState(initialPeriod); + + const { + variants, + loading: isLoadingVariants, + error: variantsError, + loadMore: loadMoreVariants, + refetch: refetchVariants, + } = useProductVariantsConnection({ + productId, + pageSize: VARIANTS_PAGE_SIZE, + skip: !productId, + }); + + useEffect(() => { + setSelectedVariantId(null); + }, [productId]); + + useEffect(() => { + setSelectedVariantId((current) => { + if (current && variants.edges.some((edge) => edge.node.id === current)) { + return current; + } + + return variants.edges[0]?.node.id ?? null; + }); + }, [variants.edges]); + + const selectedVariant = useMemo( + () => + variants.edges.find((edge) => edge.node.id === selectedVariantId)?.node ?? + null, + [selectedVariantId, variants.edges], + ); + + const pricingInput = useMemo( + () => + buildPricingInput( + selectedVariantId, + selectedVariant?.price?.currency, + fallbackCurrency, + period, + ), + [ + fallbackCurrency, + period, + selectedVariant?.price?.currency, + selectedVariantId, + ], + ); + + const pricingQueryOptions = useMemo( + () => + pricingInput + ? { + variables: { input: pricingInput }, + fetchPolicy: "cache-and-network" as const, + } + : skipToken, + [pricingInput], + ); + + const { + data: pricingData, + previousData: previousPricingData, + loading: isLoadingPricing, + error: pricingError, + refetch: refetchPricing, + } = useQuery< + ProductPricingWidgetQueryData, + ProductPricingWidgetQueryVariables + >(PRODUCT_PRICING_WIDGET_QUERY, pricingQueryOptions); + + const selectVariant = useCallback((id: string) => { + setSelectedVariantId(id); + }, []); + + const refetch = useCallback(async () => { + if (!pricingInput) { + return null; + } + + const result = await refetchPricing({ input: pricingInput }); + + return result.data?.widgetQuery.pricing ?? null; + }, [pricingInput, refetchPricing]); + + const effectivePricingData = pricingData ?? previousPricingData; + + return useMemo( + () => ({ + data: effectivePricingData?.widgetQuery.pricing ?? null, + isLoading: isLoadingVariants || isLoadingPricing, + error: variantsError ?? pricingError ?? null, + variants, + isLoadingVariants, + loadMoreVariants, + selectedVariantId, + selectVariant, + period, + setPeriod, + refetch, + refetchVariants, + }), + [ + effectivePricingData, + isLoadingPricing, + isLoadingVariants, + loadMoreVariants, + period, + pricingError, + refetch, + refetchVariants, + selectVariant, + selectedVariantId, + variants, + variantsError, + ], + ); +} diff --git a/admin/src/domains/inventory/products/hooks/use-product-variants-connection.ts b/admin/src/domains/inventory/products/hooks/use-product-variants-connection.ts new file mode 100644 index 000000000..a18cb0768 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-product-variants-connection.ts @@ -0,0 +1,216 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useApolloClient } from "@apollo/client/react"; +import type { ApiVariant, ApiVariantConnection } from "@/graphql/types"; +import { PRODUCT_VARIANTS_QUERY } from "../graphql"; +import type { + ProductVariantsQueryData, + ProductVariantsQueryVariables, +} from "../graphql/operation-types"; + +type ProductApolloClient = ReturnType; + +export const EMPTY_VARIANT_CONNECTION: ApiVariantConnection = { + __typename: "VariantConnection", + edges: [], + pageInfo: { + __typename: "PageInfo", + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 0, +}; + +export interface FetchProductVariantsPageInput { + productId: string; + first: number; + after?: string | null; +} + +export async function fetchProductVariantsPage( + client: ProductApolloClient, + input: FetchProductVariantsPageInput, +): Promise { + const result = await client.query< + ProductVariantsQueryData, + ProductVariantsQueryVariables + >({ + query: PRODUCT_VARIANTS_QUERY, + variables: { + id: input.productId, + first: input.first, + after: input.after ?? null, + }, + fetchPolicy: "network-only", + }); + + return result.data?.catalogQuery.product?.variants ?? EMPTY_VARIANT_CONNECTION; +} + +export function getVariantsFromConnection( + connection: ApiVariantConnection, +): ApiVariant[] { + return connection.edges.map((edge) => edge.node); +} + +export function appendVariantConnection( + previous: ApiVariantConnection, + next: ApiVariantConnection, +): ApiVariantConnection { + const seenIds = new Set(previous.edges.map((edge) => edge.node.id)); + const appendedEdges = next.edges.filter((edge) => !seenIds.has(edge.node.id)); + + return { + ...next, + edges: [...previous.edges, ...appendedEdges], + }; +} + +export interface UseProductVariantsConnectionOptions { + productId: string | null; + pageSize?: number; + skip?: boolean; +} + +export interface UseProductVariantsConnectionReturn { + variants: ApiVariantConnection; + loading: boolean; + error: Error | null; + loadMore: () => Promise; + refetch: () => Promise; +} + +const DEFAULT_VARIANTS_PAGE_SIZE = 20; + +export function useProductVariantsConnection({ + productId, + pageSize = DEFAULT_VARIANTS_PAGE_SIZE, + skip = false, +}: UseProductVariantsConnectionOptions): UseProductVariantsConnectionReturn { + const client = useApolloClient(); + const [variants, setVariants] = useState( + EMPTY_VARIANT_CONNECTION, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchPage = useCallback( + async (first: number, after: string | null) => { + if (!productId) { + return EMPTY_VARIANT_CONNECTION; + } + + return fetchProductVariantsPage(client, { + productId, + first, + after, + }); + }, + [client, productId], + ); + + const refetch = useCallback(async () => { + if (!productId || skip) { + return null; + } + + setLoading(true); + setError(null); + + try { + const loadedCount = Math.max(pageSize, variants.edges.length); + const connection = await fetchPage(loadedCount, null); + + setVariants(connection); + return connection; + } catch (err) { + setError(err instanceof Error ? err : new Error("Failed to load variants")); + return null; + } finally { + setLoading(false); + } + }, [fetchPage, pageSize, productId, skip, variants.edges.length]); + + useEffect(() => { + let cancelled = false; + + const loadInitialPage = async () => { + setVariants(EMPTY_VARIANT_CONNECTION); + setError(null); + + if (!productId || skip) { + setLoading(false); + return; + } + + setLoading(true); + + try { + const connection = await fetchPage(pageSize, null); + + if (!cancelled) { + setVariants(connection); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err : new Error("Failed to load variants"), + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void loadInitialPage(); + + return () => { + cancelled = true; + }; + }, [fetchPage, pageSize, productId, skip]); + + const loadMore = useCallback(async () => { + if (!productId || skip || loading || !variants.pageInfo.hasNextPage) { + return null; + } + + setLoading(true); + setError(null); + + try { + const connection = await fetchPage( + pageSize, + variants.pageInfo.endCursor ?? null, + ); + + setVariants((current) => appendVariantConnection(current, connection)); + return connection; + } catch (err) { + setError( + err instanceof Error ? err : new Error("Failed to load more variants"), + ); + return null; + } finally { + setLoading(false); + } + }, [ + fetchPage, + loading, + pageSize, + productId, + skip, + variants.pageInfo.endCursor, + variants.pageInfo.hasNextPage, + ]); + + return { + variants, + loading, + error, + loadMore, + refetch, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-product-variants-loader.ts b/admin/src/domains/inventory/products/hooks/use-product-variants-loader.ts new file mode 100644 index 000000000..a623fb144 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-product-variants-loader.ts @@ -0,0 +1,85 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useApolloClient } from "@apollo/client/react"; +import type { ApiProduct, ApiVariant } from "@/graphql/types"; +import { + fetchProductVariantsPage, + getVariantsFromConnection, +} from "./use-product-variants-connection"; + +const VARIANTS_PAGE_SIZE = 100; + +interface LoadAllProductVariantsOptions { + forceNetwork?: boolean; +} + +function getLoadedVariants(product: ApiProduct): ApiVariant[] { + return getVariantsFromConnection(product.variants); +} + +function hasCompleteVariantPage(product: ApiProduct): boolean { + return ( + !product.variants.pageInfo.hasPreviousPage && + !product.variants.pageInfo.hasNextPage && + product.variants.edges.length >= product.variants.totalCount + ); +} + +interface UseProductVariantsLoaderReturn { + loadAllProductVariants: ( + product: ApiProduct, + options?: LoadAllProductVariantsOptions, + ) => Promise; + loading: boolean; +} + +export function useProductVariantsLoader(): UseProductVariantsLoaderReturn { + const client = useApolloClient(); + const [loading, setLoading] = useState(false); + + const loadAllProductVariants = useCallback( + async ( + product: ApiProduct, + options: LoadAllProductVariantsOptions = {}, + ): Promise => { + if (!options.forceNetwork && hasCompleteVariantPage(product)) { + return getLoadedVariants(product); + } + + setLoading(true); + + try { + const variants: ApiVariant[] = []; + let after: string | null = null; + let hasNextPage = true; + + while (hasNextPage) { + const connection = await fetchProductVariantsPage(client, { + productId: product.id, + first: VARIANTS_PAGE_SIZE, + after, + }); + + variants.push(...getVariantsFromConnection(connection)); + hasNextPage = connection.pageInfo.hasNextPage; + after = connection.pageInfo.endCursor ?? null; + + if (hasNextPage && !after) { + throw new Error("Product variants pagination cursor is missing"); + } + } + + return variants; + } finally { + setLoading(false); + } + }, + [client], + ); + + return { + loadAllProductVariants, + loading, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-product.ts b/admin/src/domains/inventory/products/hooks/use-product.ts new file mode 100644 index 000000000..7237fde98 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-product.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import type { ApiProduct } from "@/graphql/types"; +import { PRODUCT_DETAILS_QUERY } from "../graphql"; +import type { + ProductDetailsQueryData, + ProductDetailsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseProductOptions { + id: string | null; + variantsFirst?: number; + variantsAfter?: string | null; + skip?: boolean; +} + +interface UseProductReturn { + product: ApiProduct | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useProduct(options: UseProductOptions): UseProductReturn { + const { + id, + variantsFirst, + variantsAfter = null, + skip = false, + } = options; + + const { data, previousData, loading, error, refetch } = useQuery< + ProductDetailsQueryData, + ProductDetailsQueryVariables + >(PRODUCT_DETAILS_QUERY, { + variables: { + id: id ?? "", + variantsFirst, + variantsAfter, + }, + skip: skip || !id, + fetchPolicy: "cache-and-network", + }); + + const product = data?.catalogQuery.product ?? null; + const previousProduct = previousData?.catalogQuery.product ?? null; + const effectiveProduct = + data === undefined && previousProduct?.id === id ? previousProduct : product; + + return { + product: effectiveProduct, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-products.ts b/admin/src/domains/inventory/products/hooks/use-products.ts index daaaaa3f7..179463b11 100644 --- a/admin/src/domains/inventory/products/hooks/use-products.ts +++ b/admin/src/domains/inventory/products/hooks/use-products.ts @@ -1,53 +1,70 @@ -import { useState, useEffect, useCallback } from "react"; -import { mockProductsList, type IProductListItem } from "@/mocks/products/products-list"; +"use client"; -interface UseProductsOptions { - /** Simulated delay in milliseconds */ - delay?: number; +import type { + ApiPageInfo, + ApiProduct, + ApiProductConnection, + ApiProductOrderByInput, + ApiProductProductsMetaInput, + ApiProductWhereInput, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { PRODUCTS_QUERY } from "../graphql"; +import type { + ProductsQueryData, + ProductsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseProductsOptions extends RelayCursorPaginationVariables { + where?: ApiProductWhereInput | null; + orderBy?: ApiProductOrderByInput[] | null; + meta?: ApiProductProductsMetaInput | null; + skip?: boolean; } -interface UseProductsReturn { - data: IProductListItem[]; - isLoading: boolean; +export interface UseProductsReturn { + products: ApiProduct[]; + connection: ApiProductConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; error: Error | null; - refetch: () => Promise; + refetch: () => Promise; } -/** - * Hook that simulates an API call to fetch products - */ export function useProducts(options: UseProductsOptions = {}): UseProductsReturn { - const { delay = 500 } = options; - - const [data, setData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchProducts = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, delay)); - - // Simulate API response - setData(mockProductsList); - } catch (err) { - setError(err instanceof Error ? err : new Error("Failed to fetch products")); - } finally { - setIsLoading(false); - } - }, [delay]); + const { + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + meta = null, + skip = false, + } = options; - useEffect(() => { - fetchProducts(); - }, [fetchProducts]); + const result = useRelayConnectionQuery< + ProductsQueryData, + ProductsQueryVariables, + ApiProduct, + ApiProductConnection + >({ + query: PRODUCTS_QUERY, + variables: { first, after, last, before, where, orderBy, meta }, + skip, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.catalogQuery.products, + }); return { - data, - isLoading, - error, - refetch: fetchProducts, + products: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, }; } diff --git a/admin/src/domains/inventory/products/hooks/use-sync-product-features.ts b/admin/src/domains/inventory/products/hooks/use-sync-product-features.ts new file mode 100644 index 000000000..d893ce029 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-sync-product-features.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiProductFeature, + ApiProductFeaturesSyncInput, +} from "@/graphql/types"; +import { PRODUCT_UPDATE_MUTATION } from "../graphql"; +import type { + ProductUpdateMutationData, + ProductUpdateMutationVariables, + ProductFeaturesSyncProduct, +} from "../graphql/operation-types"; + +interface SyncProductFeaturesResult { + product: ProductFeaturesSyncProduct | null; + features: ApiProductFeature[]; + userErrors: ApiGenericUserError[]; +} + +interface UseSyncProductFeaturesReturn { + syncProductFeatures: ( + input: ApiProductFeaturesSyncInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useSyncProductFeatures(): UseSyncProductFeaturesReturn { + const [syncProductFeaturesMutation, { loading, error, reset }] = useMutation< + ProductUpdateMutationData, + ProductUpdateMutationVariables + >(PRODUCT_UPDATE_MUTATION); + + const syncProductFeatures = useCallback( + async ( + input: ApiProductFeaturesSyncInput, + ): Promise => { + try { + const result = await syncProductFeaturesMutation({ + variables: { + productId: input.productId, + operations: { features: input.features }, + }, + }); + + const payload = result.data?.catalogMutation.productUpdate; + + return { + product: payload?.product ?? null, + features: payload?.product?.features ?? [], + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + + return { + product: null, + features: [], + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [syncProductFeaturesMutation], + ); + + return { + syncProductFeatures, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-sync-product-options.ts b/admin/src/domains/inventory/products/hooks/use-sync-product-options.ts new file mode 100644 index 000000000..3e73ad584 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-sync-product-options.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiProductOption, + ApiProductOptionsSyncInput, +} from "@/graphql/types"; +import { PRODUCT_UPDATE_MUTATION } from "../graphql"; +import type { + ProductUpdateMutationData, + ProductUpdateMutationVariables, + ProductOptionsSyncProduct, +} from "../graphql/operation-types"; + +interface SyncProductOptionsResult { + product: ProductOptionsSyncProduct | null; + options: ApiProductOption[]; + userErrors: ApiGenericUserError[]; +} + +interface UseSyncProductOptionsReturn { + syncProductOptions: ( + input: ApiProductOptionsSyncInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useSyncProductOptions(): UseSyncProductOptionsReturn { + const [syncProductOptionsMutation, { loading, error, reset }] = useMutation< + ProductUpdateMutationData, + ProductUpdateMutationVariables + >(PRODUCT_UPDATE_MUTATION); + + const syncProductOptions = useCallback( + async ( + input: ApiProductOptionsSyncInput, + ): Promise => { + try { + const result = await syncProductOptionsMutation({ + variables: { + productId: input.productId, + operations: { options: input.options }, + }, + }); + + const payload = result.data?.catalogMutation.productUpdate; + + return { + product: payload?.product ?? null, + options: payload?.product?.options ?? [], + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + + return { + product: null, + options: [], + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [syncProductOptionsMutation], + ); + + return { + syncProductOptions, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-update-product-status.ts b/admin/src/domains/inventory/products/hooks/use-update-product-status.ts new file mode 100644 index 000000000..651d56d8d --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-update-product-status.ts @@ -0,0 +1,84 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiProduct, +} from "@/graphql/types"; +import { ProductStatus } from "@/graphql/types"; +import { PRODUCT_UPDATE_MUTATION } from "../graphql"; +import type { + ProductUpdateMutationData, + ProductUpdateMutationVariables, +} from "../graphql/operation-types"; + +export interface ProductStatusUpdateInput { + productId: string; + published: boolean; + expectedRevision?: number | null; +} + +interface UpdateProductStatusResult { + product: ApiProduct | null; + userErrors: ApiGenericUserError[]; +} + +interface UseUpdateProductStatusReturn { + updateProductStatus: ( + input: ProductStatusUpdateInput, + ) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useUpdateProductStatus(): UseUpdateProductStatusReturn { + const [updateStatusMutation, { loading, error, reset }] = useMutation< + ProductUpdateMutationData, + ProductUpdateMutationVariables + >(PRODUCT_UPDATE_MUTATION); + + const updateProductStatus = useCallback( + async ( + input: ProductStatusUpdateInput, + ): Promise => { + try { + const result = await updateStatusMutation({ + variables: { + productId: input.productId, + expectedRevision: input.expectedRevision ?? undefined, + operations: { + status: input.published + ? ProductStatus.Published + : ProductStatus.Draft, + }, + }, + }); + + const payload = result.data?.catalogMutation.productUpdate; + + return { + product: payload?.product ?? null, + userErrors: payload?.userErrors ?? [], + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + + return { + product: null, + userErrors: [{ message, code: "UNEXPECTED_ERROR" }], + }; + } + }, + [updateStatusMutation], + ); + + return { + updateProductStatus, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-update-product.ts b/admin/src/domains/inventory/products/hooks/use-update-product.ts new file mode 100644 index 000000000..cd1519179 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-update-product.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation } from "@apollo/client/react"; +import type { + ApiGenericUserError, + ApiOperationResult, + ApiProduct, + ApiProductUpdateInput, +} from "@/graphql/types"; +import { PRODUCT_UPDATE_MUTATION } from "../graphql"; +import type { + ProductUpdateMutationData, + ProductUpdateMutationVariables, +} from "../graphql/operation-types"; +import { normalizeProductUpdateErrors } from "../mappers/product-errors.mapper"; + +interface UpdateProductInput { + productId: string; + operations: ApiProductUpdateInput; + expectedRevision?: number | null; +} + +interface UpdateProductResult { + product: ApiProduct | null; + operationResults: ApiOperationResult[]; + userErrors: ApiGenericUserError[]; + errors: ApiGenericUserError[]; +} + +interface UseUpdateProductReturn { + updateProduct: (input: UpdateProductInput) => Promise; + loading: boolean; + error: Error | null; + reset: () => void; +} + +export function useUpdateProduct(): UseUpdateProductReturn { + const [updateProductMutation, { loading, error, reset }] = useMutation< + ProductUpdateMutationData, + ProductUpdateMutationVariables + >(PRODUCT_UPDATE_MUTATION); + + const updateProduct = useCallback( + async (input: UpdateProductInput): Promise => { + try { + const result = await updateProductMutation({ + variables: { + productId: input.productId, + operations: input.operations, + expectedRevision: input.expectedRevision ?? null, + }, + }); + + const payload = result.data?.catalogMutation.productUpdate; + + const operationResults = payload?.operationResults ?? []; + const userErrors = payload?.userErrors ?? []; + + return { + product: payload?.product ?? null, + operationResults, + userErrors, + errors: normalizeProductUpdateErrors({ + operationResults, + userErrors, + }), + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + const userErrors = [{ message, code: "UNEXPECTED_ERROR" }]; + + return { + product: null, + operationResults: [], + userErrors, + errors: userErrors, + }; + } + }, + [updateProductMutation], + ); + + return { + updateProduct, + loading, + error: error ?? null, + reset, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-variants.ts b/admin/src/domains/inventory/products/hooks/use-variants.ts new file mode 100644 index 000000000..b1c0a54d4 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-variants.ts @@ -0,0 +1,117 @@ +"use client"; + +import type { + ApiPageInfo, + ApiVariant, + ApiVariantConnection, + ApiVariantOrderByInput, + ApiVariantWhereInput, + ApiWarehouseAssignableVariantOrderByInput, + ApiWarehouseAssignableVariantWhereInput, +} from "@/graphql/types"; +import { useRelayConnectionQuery } from "@/graphql/hooks/use-relay-connection-query"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { VARIANTS_QUERY, WAREHOUSE_ASSIGNABLE_VARIANTS_QUERY } from "../graphql"; +import type { + VariantsQueryData, + VariantsQueryVariables, + WarehouseAssignableVariantsQueryData, + WarehouseAssignableVariantsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseVariantsOptions extends RelayCursorPaginationVariables { + where?: ApiVariantWhereInput | null; + orderBy?: ApiVariantOrderByInput[] | null; + skip?: boolean; +} + +export interface UseVariantsReturn { + variants: ApiVariant[]; + connection: ApiVariantConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useVariants(options: UseVariantsOptions = {}): UseVariantsReturn { + const { + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + skip = false, + } = options; + + const result = useRelayConnectionQuery< + VariantsQueryData, + VariantsQueryVariables, + ApiVariant, + ApiVariantConnection + >({ + query: VARIANTS_QUERY, + variables: { first, after, last, before, where, orderBy }, + skip, + fetchPolicy: "cache-and-network", + getConnection: (data) => data?.catalogQuery.variants, + }); + + return { + variants: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} + +export interface UseWarehouseAssignableVariantsOptions + extends Omit { + warehouseId: string; + where?: ApiWarehouseAssignableVariantWhereInput | null; + orderBy?: ApiWarehouseAssignableVariantOrderByInput[] | null; +} + +export function useWarehouseAssignableVariants( + options: UseWarehouseAssignableVariantsOptions, +): UseVariantsReturn { + const { + warehouseId, + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + skip = false, + } = options; + + const result = useRelayConnectionQuery< + WarehouseAssignableVariantsQueryData, + WarehouseAssignableVariantsQueryVariables, + ApiVariant, + ApiVariantConnection + >({ + query: WAREHOUSE_ASSIGNABLE_VARIANTS_QUERY, + variables: { warehouseId, first, after, last, before, where, orderBy }, + skip, + fetchPolicy: "cache-and-network", + getConnection: (data) => + data?.inventoryQuery.warehouseAssignableVariants, + }); + + return { + variants: result.nodes, + connection: result.connection, + totalCount: result.totalCount, + pageInfo: result.pageInfo, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/admin/src/domains/inventory/products/hooks/use-vendors.ts b/admin/src/domains/inventory/products/hooks/use-vendors.ts new file mode 100644 index 000000000..89c7ebf06 --- /dev/null +++ b/admin/src/domains/inventory/products/hooks/use-vendors.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useQuery } from "@apollo/client/react"; +import type { + ApiPageInfo, + ApiVendor, + ApiVendorConnection, + ApiVendorOrderByInput, + ApiVendorWhereInput, +} from "@/graphql/types"; +import type { RelayCursorPaginationVariables } from "@/ui-kit/cursor-pagination"; +import { VENDORS_QUERY } from "../graphql"; +import type { + VendorsQueryData, + VendorsQueryVariables, +} from "../graphql/operation-types"; + +export interface UseVendorsOptions extends RelayCursorPaginationVariables { + where?: ApiVendorWhereInput | null; + orderBy?: ApiVendorOrderByInput[] | null; + skip?: boolean; + fetchPolicy?: "cache-and-network" | "network-only"; +} + +interface UseVendorsReturn { + vendors: ApiVendor[]; + connection: ApiVendorConnection | null; + totalCount: number; + pageInfo: ApiPageInfo | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useVendors( + options: UseVendorsOptions = {}, +): UseVendorsReturn { + const { + first, + after = null, + last, + before = null, + where = null, + orderBy = null, + skip = false, + fetchPolicy = "cache-and-network", + } = options; + + const { data, previousData, loading, error, refetch } = useQuery< + VendorsQueryData, + VendorsQueryVariables + >(VENDORS_QUERY, { + variables: { first, after, last, before, where, orderBy }, + skip, + fetchPolicy, + }); + + const effectiveData = data ?? previousData; + const connection = effectiveData?.catalogQuery.vendors ?? null; + + return { + vendors: connection?.edges.map((edge) => edge.node) ?? [], + connection, + totalCount: connection?.totalCount ?? 0, + pageInfo: connection?.pageInfo ?? null, + loading, + error: error ?? null, + refetch: () => refetch(), + }; +} diff --git a/admin/src/domains/inventory/products/list-page/filter-schema.ts b/admin/src/domains/inventory/products/list-page/filter-schema.ts new file mode 100644 index 000000000..c5efb7881 --- /dev/null +++ b/admin/src/domains/inventory/products/list-page/filter-schema.ts @@ -0,0 +1,43 @@ +import { + FilterType, + priceOperators, + relationOperators, +} from "@/layouts/filters"; +import type { IFilterSchema } from "@/layouts/filters/core/types"; + +export const productLikeBaseFilterSchema: IFilterSchema[] = [ + { + key: "primaryCategory", + label: "Primary category", + description: "Filter by primary category", + type: FilterType.Relation, + operators: relationOperators, + payloadKey: "primaryCategoryId", + entity: "category", + }, + { + key: "minPrice", + label: "Min price", + description: "Filter by minimum price", + type: FilterType.Price, + operators: priceOperators, + payloadKey: "minPriceMinor", + }, + { + key: "maxPrice", + label: "Max price", + description: "Filter by maximum price", + type: FilterType.Price, + operators: priceOperators, + payloadKey: "maxPriceMinor", + }, + { + key: "brand", + label: "Brand", + description: "Filter by brand/vendor", + type: FilterType.Relation, + operators: relationOperators, + payloadKey: "vendorId", + entity: "vendor", + }, +]; diff --git a/admin/src/domains/inventory/products/list-page/index.ts b/admin/src/domains/inventory/products/list-page/index.ts new file mode 100644 index 000000000..0ce7d8bad --- /dev/null +++ b/admin/src/domains/inventory/products/list-page/index.ts @@ -0,0 +1,2 @@ +export * from "./filter-schema"; +export * from "./page-config"; diff --git a/admin/src/domains/inventory/products/list-page/page-config.ts b/admin/src/domains/inventory/products/list-page/page-config.ts new file mode 100644 index 000000000..2eba97ccd --- /dev/null +++ b/admin/src/domains/inventory/products/list-page/page-config.ts @@ -0,0 +1,104 @@ +import { + createMinorUnitPriceTransformer, + createRelationInTransformer, +} from "@/hooks"; +import type { + FilterTransformer, + OrderByInput, + SortFieldMapping, + UsePageConfigReturn, +} from "@/hooks"; + +type ProductLikeCommonOrderField = + | "Name" + | "MinPriceMinor" + | "MaxPriceMinor" + | "PrimaryCategoryName" + | "BrandName"; + +export type ProductLikeOrderFieldEnum = { + Name: string; +} & Partial, string>>; + +export function createProductLikeSortFieldMapping( + fields: ProductLikeOrderFieldEnum, +): SortFieldMapping { + const mapping: Record = { + title: fields.Name, + }; + + if (fields.MinPriceMinor) mapping.minPriceMinor = fields.MinPriceMinor; + if (fields.MaxPriceMinor) mapping.maxPriceMinor = fields.MaxPriceMinor; + if (fields.PrimaryCategoryName) { + mapping.primaryCategoryName = fields.PrimaryCategoryName; + } + if (fields.BrandName) mapping.brand = fields.BrandName; + + return mapping as SortFieldMapping; +} + +export function buildProductLikeSearchCondition( + search: string, +): Partial { + return { name: { _containsi: search } } as unknown as Partial; +} + +export function createProductLikeFilterTransformers< + TWhereInput extends object, +>(): Record> { + return { + primaryCategoryId: + createRelationInTransformer("primaryCategoryId"), + minPriceMinor: + createMinorUnitPriceTransformer("minPriceMinor"), + maxPriceMinor: + createMinorUnitPriceTransformer("maxPriceMinor"), + vendorId: createRelationInTransformer("vendorId"), + }; +} + +export function buildProductLikeQueryVariables< + TVariables, + TWhereInput extends object, + TOrderField extends string, + TOrderByInput, +>( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): TVariables { + return { + first: pageConfig.first, + after: pageConfig.after, + last: pageConfig.last, + before: pageConfig.before, + where: pageConfig.where ?? null, + orderBy: (pageConfig.orderBy ?? null) as TOrderByInput[] | null, + } as TVariables; +} + +export function toProductLikeQueryVariables< + TVariables, + TWhereInput extends object, + TOrderField extends string, + TOrderByInput, +>( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): TVariables { + return buildProductLikeQueryVariables< + TVariables, + TWhereInput, + TOrderField, + TOrderByInput + >({ + ...pageConfig, + where: pageConfig.where as TWhereInput | undefined, + orderBy: pageConfig.orderBy as + | OrderByInput[] + | undefined, + }); +} diff --git a/admin/src/domains/inventory/products/mappers/index.ts b/admin/src/domains/inventory/products/mappers/index.ts new file mode 100644 index 000000000..43cd6639b --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/index.ts @@ -0,0 +1,9 @@ +export * from "./product-create.mapper"; +export * from "./product-errors.mapper"; +export * from "./product-features.mapper"; +export * from "./product-feature-errors.mapper"; +export * from "./product-options.mapper"; +export * from "./product-option-errors.mapper"; +export * from "./product-variant-editor.mapper"; +export * from "./product-variant-update.mapper"; +export * from "./product-variant-options.mapper"; diff --git a/admin/src/domains/inventory/products/mappers/product-create.mapper.ts b/admin/src/domains/inventory/products/mappers/product-create.mapper.ts new file mode 100644 index 000000000..044141fac --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-create.mapper.ts @@ -0,0 +1,102 @@ +import type { OutputData } from "@editorjs/editorjs"; +import { slugify } from "transliteration/dist/node/src/node/index.js"; +import type { ApiFile, ApiProductCreateInput } from "@/graphql/types"; +import { renderContent } from "@/ui-kit/editor"; +import type { + IGeneratedVariant, + IOptionInput, +} from "../modals/create-product-modal/utils/generate-variants"; + +export type { IGeneratedVariant, IOptionInput }; + +export interface CreateProductInput { + title: string; + handle: string; + description: OutputData | null; + media: ApiFile[]; + hasVariants: boolean; + options: IOptionInput[]; + variants: IGeneratedVariant[]; +} + +export function prepareDescription( + description: OutputData | null, +): ApiProductCreateInput["description"] { + if (!description || !description.blocks?.length) { + return undefined; + } + + const rendered = renderContent(description); + + return { + text: rendered.plain, + html: rendered.html, + json: rendered.json as unknown as Record, + }; +} + +export function prepareMediaFileIds(media: ApiFile[]): string[] | undefined { + if (!media || media.length === 0) { + return undefined; + } + + return media.map((item) => item.id); +} + +export function prepareOptions( + hasVariants: boolean, + options: IOptionInput[], +): ApiProductCreateInput["options"] { + if (!hasVariants || !options || options.length === 0) { + return undefined; + } + + const validOptions = options.filter( + (option) => option.name.trim() && option.values.length > 0, + ); + + if (validOptions.length === 0) { + return undefined; + } + + return validOptions.map((option) => ({ + name: option.name, + slug: slugify(option.name), + values: option.values.map((value) => ({ + name: value.value, + slug: value.slug, + })), + })); +} + +export function prepareVariants( + hasVariants: boolean, + variants: IGeneratedVariant[], +): ApiProductCreateInput["variants"] { + if (!hasVariants || !variants || variants.length === 0) { + return undefined; + } + + const enabledVariants = variants.filter((variant) => variant.enabled); + + if (enabledVariants.length === 0) { + return undefined; + } + + return enabledVariants.map((variant) => ({ + handle: variant.id, + })); +} + +export function prepareProductPayload( + input: CreateProductInput, +): ApiProductCreateInput { + return { + title: input.title, + handle: input.handle, + description: prepareDescription(input.description), + mediaFileIds: prepareMediaFileIds(input.media), + options: prepareOptions(input.hasVariants, input.options), + variants: prepareVariants(input.hasVariants, input.variants), + }; +} diff --git a/admin/src/domains/inventory/products/mappers/product-errors.mapper.ts b/admin/src/domains/inventory/products/mappers/product-errors.mapper.ts new file mode 100644 index 000000000..e2988f9eb --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-errors.mapper.ts @@ -0,0 +1,277 @@ +import type { + ApiGenericUserError, + ApiOperationResult, + ApiProductUpdateInput, + ApiVariantOperationInput, +} from "@/graphql/types"; +import { OperationType, VariantOperationAction } from "@/graphql/types"; +import type { VariantEditorSaveRow } from "./product-variant-editor.mapper"; + +export type ProductFormErrorField = + | "title" + | "handle" + | "description" + | "media" + | "options" + | "variants"; + +export interface ProductFormError { + field: ProductFormErrorField; + message: string; +} + +const FIELD_ALIASES: Record = { + title: "title", + handle: "handle", + description: "description", + media: "media", + mediaFileIds: "media", + options: "options", + variants: "variants", +}; + +export function mapProductUserErrorToFormError( + error: ApiGenericUserError, +): ProductFormError | null { + const fieldPath = error.field ?? []; + const field = [...fieldPath].reverse().find((part) => FIELD_ALIASES[part]); + + if (!field) { + return null; + } + + return { + field: FIELD_ALIASES[field], + message: error.message, + }; +} + +export function mapProductUserErrorsToFormErrors( + errors: ApiGenericUserError[], +): ProductFormError[] { + return errors + .map((error) => mapProductUserErrorToFormError(error)) + .filter((error): error is ProductFormError => error !== null); +} + +export interface ProductUpdateErrorSource { + userErrors: ApiGenericUserError[]; + operationResults: ApiOperationResult[]; +} + +function formatOperationType(type: ApiOperationResult["type"]): string { + return type + .toLowerCase() + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function normalizeProductUpdateErrors( + result: ProductUpdateErrorSource, +): ApiGenericUserError[] { + const errors = [...result.userErrors]; + + for (const operationResult of result.operationResults) { + errors.push(...operationResult.errors); + + if (!operationResult.applied && operationResult.errors.length === 0) { + errors.push({ + code: "OPERATION_NOT_APPLIED", + message: `${formatOperationType(operationResult.type)} was not applied.`, + }); + } + } + + return errors; +} + +export interface VariantOperationRowStateInput { + existingRows: VariantEditorSaveRow[]; + draftRows: VariantEditorSaveRow[]; + deletedRows: VariantEditorSaveRow[]; + additionalOperations?: ApiProductUpdateInput; + submittedVariantOperations?: ApiVariantOperationInput[]; + operationResults: ApiOperationResult[]; + userErrors: ApiGenericUserError[]; +} + +export interface VariantOperationRowState { + rowErrors: Record; + materializedDraftRows: Array<{ + clientMutationId: string; + entityId: string; + applied: boolean; + errors: ApiGenericUserError[]; + }>; + appliedDeletedRowIds: string[]; + firstMessage: string | null; +} + +function getVariantIndexFromFieldPath( + fieldPath: readonly string[] | null | undefined, +): number | null { + if (!fieldPath) { + return null; + } + + const variantsIndex = fieldPath.findIndex((part) => part === "variants"); + if (variantsIndex < 0) { + return null; + } + + const maybeIndex = Number(fieldPath[variantsIndex + 1]); + + return Number.isInteger(maybeIndex) ? maybeIndex : null; +} + +function collectErrorMessage(errors: ApiGenericUserError[]): string | null { + if (errors.length === 0) { + return null; + } + + return errors.map((error) => error.message).join(" "); +} + +function getFallbackRowForVariantIndex( + input: VariantOperationRowStateInput, + index: number, +): VariantEditorSaveRow | null { + const submittedOperation = input.submittedVariantOperations?.[index]; + const additionalOperation = input.additionalOperations?.variants?.[index]; + const operation = submittedOperation ?? additionalOperation; + + if (operation?.action === VariantOperationAction.Create) { + return input.draftRows.find( + (row) => row.clientMutationId === operation.clientMutationId, + ) ?? null; + } + + if (operation?.variantId) { + return [...input.existingRows, ...input.deletedRows].find( + (row) => row.id === operation.variantId, + ) ?? null; + } + + return [...input.existingRows, ...input.draftRows][index] ?? null; +} + +export function mapVariantOperationResultsToRowState( + input: VariantOperationRowStateInput, +): VariantOperationRowState { + const rowErrors: Record = {}; + const materializedDraftRows: VariantOperationRowState["materializedDraftRows"] = + []; + const appliedDeletedRowIds: string[] = []; + const draftRowsByClientMutationId = new Map( + input.draftRows + .filter((row) => row.clientMutationId) + .map((row) => [row.clientMutationId as string, row]), + ); + const existingRowsById = new Map( + input.existingRows.map((row) => [row.id, row]), + ); + const deletedRowsById = new Map( + input.deletedRows.map((row) => [row.id, row]), + ); + let firstMessage: string | null = null; + + for (const [index, operationResult] of input.operationResults.entries()) { + const message = + collectErrorMessage(operationResult.errors) ?? + (!operationResult.applied + ? `${formatOperationType(operationResult.type)} was not applied.` + : null); + + if (!firstMessage && message) { + firstMessage = message; + } + + if (operationResult.type === OperationType.VariantCreate) { + const fallbackRow = getFallbackRowForVariantIndex(input, index); + const clientMutationId = + operationResult.clientMutationId ?? + fallbackRow?.clientMutationId ?? + undefined; + const draftRow = clientMutationId + ? draftRowsByClientMutationId.get(clientMutationId) + : undefined; + + if (clientMutationId && operationResult.entityId) { + materializedDraftRows.push({ + clientMutationId, + entityId: operationResult.entityId, + applied: operationResult.applied, + errors: operationResult.errors, + }); + } + + const rowId = operationResult.entityId ?? draftRow?.id; + if (rowId && message) { + rowErrors[rowId] = message; + } + continue; + } + + if (operationResult.type === OperationType.VariantUpdate) { + const rowId = + operationResult.entityId ?? + getFallbackRowForVariantIndex(input, index)?.id; + + if (rowId && message && existingRowsById.has(rowId)) { + rowErrors[rowId] = message; + } else if (rowId && message) { + rowErrors[rowId] = message; + } + continue; + } + + if (operationResult.type === OperationType.VariantDelete) { + const rowId = + operationResult.entityId ?? + getFallbackRowForVariantIndex(input, index)?.id; + + if (rowId && operationResult.applied) { + appliedDeletedRowIds.push(rowId); + } + + if (rowId && message && deletedRowsById.has(rowId)) { + rowErrors[rowId] = message; + } + } + } + + for (const userError of input.userErrors) { + const index = getVariantIndexFromFieldPath(userError.field); + + if (index === null) { + if (!firstMessage) { + firstMessage = userError.message; + } + continue; + } + + const row = getFallbackRowForVariantIndex(input, index); + if (!row) { + if (!firstMessage) { + firstMessage = userError.message; + } + continue; + } + + rowErrors[row.id] = rowErrors[row.id] + ? `${rowErrors[row.id]} ${userError.message}` + : userError.message; + + if (!firstMessage) { + firstMessage = userError.message; + } + } + + return { + rowErrors, + materializedDraftRows, + appliedDeletedRowIds, + firstMessage, + }; +} diff --git a/admin/src/domains/inventory/products/mappers/product-feature-errors.mapper.ts b/admin/src/domains/inventory/products/mappers/product-feature-errors.mapper.ts new file mode 100644 index 000000000..6c630b109 --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-feature-errors.mapper.ts @@ -0,0 +1,83 @@ +import type { ApiGenericUserError } from "@/graphql/types"; +import type { + AttributeEditorRow, + AttributeEditorValue, +} from "../modals/edit-attributes-modal/types"; +import type { ProductFeaturesSyncDraft } from "./product-features.mapper"; + +export interface ProductFeatureEditorErrorMap { + rows: Record; + values: Record; + modal: string[]; +} + +function formatFieldPath(field: string[] | null | undefined): string | null { + if (!field || field.length === 0) { + return null; + } + + return field.join("."); +} + +export function formatProductFeatureUserError( + error: ApiGenericUserError, +): string { + const fieldPath = formatFieldPath(error.field); + + return fieldPath ? `${fieldPath}: ${error.message}` : error.message; +} + +export function formatProductFeatureUserErrors( + errors: ApiGenericUserError[], +): string[] { + return errors.map(formatProductFeatureUserError); +} + +function pushError( + target: Record, + key: string, + message: string, +): void { + target[key] = [...(target[key] ?? []), message]; +} + +export function mapProductFeatureUserErrorsToEditorErrors(input: { + errors: ApiGenericUserError[]; + draft: ProductFeaturesSyncDraft; +}): ProductFeatureEditorErrorMap { + const errorMap: ProductFeatureEditorErrorMap = { + rows: {}, + values: {}, + modal: [], + }; + + input.errors.forEach((error) => { + const field = error.field ?? []; + const featureIndex = Number(field[1]); + const valueIndex = Number(field[3]); + const featureRow: AttributeEditorRow | undefined = Number.isInteger(featureIndex) + ? input.draft.featureRowsByInputIndex[featureIndex] + : undefined; + + if (!featureRow) { + errorMap.modal.push(formatProductFeatureUserError(error)); + return; + } + + if (field[2] === "values" && Number.isInteger(valueIndex)) { + const value: AttributeEditorValue | undefined = + input.draft.valuesByInputPath[ + `features.${featureIndex}.values.${valueIndex}` + ]; + + if (value) { + pushError(errorMap.values, value.id, error.message); + return; + } + } + + pushError(errorMap.rows, featureRow.id, error.message); + }); + + return errorMap; +} diff --git a/admin/src/domains/inventory/products/mappers/product-features.mapper.ts b/admin/src/domains/inventory/products/mappers/product-features.mapper.ts new file mode 100644 index 000000000..4c9317a82 --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-features.mapper.ts @@ -0,0 +1,782 @@ +import { slugify } from "transliteration/dist/node/src/node/index.js"; +import type { + ApiGenericUserError, + ApiProductFeature, + ApiProductFeatureSyncItemInput, + ApiProductFeaturesSyncInput, +} from "@/graphql/types"; +import type { + AttributeEditorRow, + AttributeEditorValue, +} from "../modals/edit-attributes-modal/types"; + +const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + +type UserErrorField = Array; + +export interface ProductFeaturesSyncDraft { + input: ApiProductFeaturesSyncInput; + featureRowsByInputIndex: AttributeEditorRow[]; + valuesByInputPath: Record; +} + +function createTemporaryId(prefix: string): string { + const uuid = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; + return `${prefix}-${uuid}`; +} + +export function createTemporaryFeatureId(): string { + return createTemporaryId("tmp-feature"); +} + +export function createTemporaryValueId(): string { + return createTemporaryId("tmp-value"); +} + +function compareIndex(left: number[], right: number[]): number { + const length = Math.max(left.length, right.length); + + for (let i = 0; i < length; i += 1) { + const leftValue = left[i] ?? -1; + const rightValue = right[i] ?? -1; + + if (leftValue !== rightValue) { + return leftValue - rightValue; + } + } + + return left.length - right.length; +} + +function isNonNegativeInteger(value: number): boolean { + return Number.isInteger(value) && value >= 0; +} + +function isValidFeatureIndex(index: number[]): boolean { + return ( + (index.length === 1 || index.length === 2) && + index.every(isNonNegativeInteger) + ); +} + +function isValidSlug(slug: string): boolean { + return SLUG_PATTERN.test(slug); +} + +function makeUserError( + message: string, + field?: UserErrorField, + code = "VALIDATION_ERROR", +): ApiGenericUserError { + return { + code, + field: field?.map(String), + message, + }; +} + +function normalizeName(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function normalizeSlug(value: string, fallback: string): string { + const fallbackSlug = + fallback + .toLocaleLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") || "item"; + + const slug = + slugify(value) + .toLocaleLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") || fallbackSlug; + + if (isValidSlug(slug)) { + return slug; + } + + return isValidSlug(fallbackSlug) ? fallbackSlug : "item"; +} + +export function toFeatureSlug(name: string, fallback: string): string { + return normalizeSlug(name, fallback); +} + +function toValueSlug(name: string, fallback: string): string { + return normalizeSlug(name, fallback); +} + +export function toUniqueSlug(slug: string, used: Set): string { + const base = isValidSlug(slug) ? slug : "item"; + let candidate = base; + let suffix = 2; + + while (used.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + + used.add(candidate); + return candidate; +} + +function sortedRows(rows: AttributeEditorRow[]): AttributeEditorRow[] { + return [...rows].sort((left, right) => { + if (left.sortIndex !== right.sortIndex) { + return left.sortIndex - right.sortIndex; + } + + return left.id.localeCompare(right.id); + }); +} + +function sortedValues(values: AttributeEditorValue[]): AttributeEditorValue[] { + return [...values].sort((left, right) => { + if (left.sortIndex !== right.sortIndex) { + return left.sortIndex - right.sortIndex; + } + + return left.id.localeCompare(right.id); + }); +} + +function getRowsInInputOrder(rows: AttributeEditorRow[]): AttributeEditorRow[] { + const rootRows = sortedRows(rows.filter((row) => row.parentId === null)); + const result: AttributeEditorRow[] = []; + + for (const rootRow of rootRows) { + result.push(rootRow); + + if (rootRow.type !== "group") { + continue; + } + + result.push( + ...sortedRows( + rows.filter( + (row) => row.parentId === rootRow.id && row.type === "attribute", + ), + ), + ); + } + + for (const row of rows) { + if (!result.some((orderedRow) => orderedRow.id === row.id)) { + result.push(row); + } + } + + return result; +} + +function getRowInputIndexMap(rows: AttributeEditorRow[]): Map { + return new Map( + getRowsInInputOrder(rows).map((row, index) => [row.id, index]), + ); +} + +export function apiProductFeaturesToAttributeEditorRows( + features: ApiProductFeature[], +): AttributeEditorRow[] { + const sortedFeatures = [...features].sort((left, right) => + compareIndex(left.index, right.index), + ); + const groupIdByIndex = new Map(); + const rows: AttributeEditorRow[] = []; + + for (const feature of sortedFeatures) { + if (feature.isGroup && feature.index.length === 1) { + groupIdByIndex.set(feature.index.join("."), feature.id); + } + } + + for (const feature of sortedFeatures) { + if (!isValidFeatureIndex(feature.index)) { + continue; + } + + if (feature.isGroup && feature.index.length !== 1) { + continue; + } + + if (!feature.isGroup && feature.index.length === 2) { + const parentId = groupIdByIndex.get(feature.index.slice(0, -1).join(".")); + + if (!parentId) { + continue; + } + + rows.push({ + id: feature.id, + apiId: feature.id, + apiType: "attribute", + type: "attribute", + name: feature.name, + slug: feature.slug, + parentId, + sortIndex: feature.index[1], + level: 1, + values: sortedValues( + feature.values.map((value) => ({ + id: value.id, + apiId: value.id, + name: value.name, + slug: value.slug, + sortIndex: value.index, + })), + ), + }); + continue; + } + + rows.push({ + id: feature.id, + apiId: feature.id, + apiType: feature.isGroup ? "group" : "attribute", + type: feature.isGroup ? "group" : "attribute", + name: feature.name, + slug: feature.slug, + parentId: null, + sortIndex: feature.index[0], + level: 0, + values: feature.isGroup + ? [] + : sortedValues( + feature.values.map((value) => ({ + id: value.id, + apiId: value.id, + name: value.name, + slug: value.slug, + sortIndex: value.index, + })), + ), + }); + } + + return rows; +} + +export function getProductFeatureEditorLoadErrors( + features: ApiProductFeature[], +): ApiGenericUserError[] { + const errors: ApiGenericUserError[] = []; + const rootGroupIndexKeys = new Set( + features + .filter((feature) => feature.isGroup && feature.index.length === 1) + .map((feature) => feature.index.join(".")), + ); + + features.forEach((feature, featureIndex) => { + if (!isValidFeatureIndex(feature.index)) { + errors.push( + makeUserError( + `Feature "${feature.name}" has an unsupported tree index.`, + ["features", featureIndex, "index"], + ), + ); + return; + } + + if (feature.isGroup && feature.index.length !== 1) { + errors.push( + makeUserError( + `Group "${feature.name}" is nested and cannot be edited safely.`, + ["features", featureIndex, "index"], + ), + ); + return; + } + + if (!feature.isGroup && feature.index.length === 2) { + const parentIndexKey = feature.index.slice(0, -1).join("."); + + if (!rootGroupIndexKeys.has(parentIndexKey)) { + errors.push( + makeUserError( + `Attribute "${feature.name}" has no matching parent group and cannot be edited safely.`, + ["features", featureIndex, "index"], + ), + ); + } + } + }); + + return errors; +} + +export function parseAttributeValuesText(input: { + text: string; + existingValues: AttributeEditorValue[]; +}): AttributeEditorValue[] { + const parsedNames = input.text + .split(",") + .map((segment) => segment.trim()) + .filter(Boolean); + const existingValues = sortedValues(input.existingValues); + const usedExistingIds = new Set(); + const existingByName = new Map(); + + for (const value of existingValues) { + const normalized = normalizeName(value.name); + const matches = existingByName.get(normalized) ?? []; + matches.push(value); + existingByName.set(normalized, matches); + } + + const usedSlugs = new Set(); + + return parsedNames.map((name, index) => { + const normalizedName = normalizeName(name); + const exactMatch = existingByName + .get(normalizedName) + ?.find((value) => !usedExistingIds.has(value.id)); + const sameIndexValue = existingValues[index]; + const matchedValue = + exactMatch ?? + (sameIndexValue && !usedExistingIds.has(sameIndexValue.id) + ? sameIndexValue + : undefined); + + if (matchedValue) { + usedExistingIds.add(matchedValue.id); + } + + const slug = toUniqueSlug(toValueSlug(name, `value-${index + 1}`), usedSlugs); + + return { + id: matchedValue?.id ?? createTemporaryValueId(), + apiId: matchedValue?.apiId, + name, + slug, + sortIndex: index, + }; + }); +} + +function createFeatureSyncItem(input: { + row: AttributeEditorRow; + index: number[]; + inputIndex: number; + usedFeatureSlugs: Set; +}): ApiProductFeatureSyncItemInput { + const slug = toUniqueSlug( + toFeatureSlug(input.row.name, `feature-${input.inputIndex + 1}`), + input.usedFeatureSlugs, + ); + const item: ApiProductFeatureSyncItemInput = { + index: input.index, + isGroup: input.row.type === "group", + name: input.row.name.trim(), + slug, + }; + + if (input.row.apiId) { + item.id = input.row.apiId; + } + + if (input.row.type === "attribute") { + const usedValueSlugs = new Set(); + item.values = sortedValues(input.row.values).map((value, valueIndex) => { + const valueInput = { + index: valueIndex, + name: value.name.trim(), + slug: toUniqueSlug( + toValueSlug(value.name, `value-${valueIndex + 1}`), + usedValueSlugs, + ), + }; + + if (value.apiId) { + return { + ...valueInput, + id: value.apiId, + }; + } + + return valueInput; + }); + } + + return item; +} + +export function buildProductFeaturesSyncDraft(input: { + productId: string; + rows: AttributeEditorRow[]; +}): ProductFeaturesSyncDraft { + const features: ApiProductFeatureSyncItemInput[] = []; + const featureRowsByInputIndex: AttributeEditorRow[] = []; + const valuesByInputPath: Record = {}; + const usedFeatureSlugs = new Set(); + const rootRows = sortedRows( + input.rows.filter( + (row) => + row.parentId === null && + (row.type === "group" || row.type === "attribute"), + ), + ); + + rootRows.forEach((rootRow, rootIndex) => { + const inputIndex = features.length; + const item = createFeatureSyncItem({ + row: rootRow, + index: [rootIndex], + inputIndex, + usedFeatureSlugs, + }); + features.push(item); + featureRowsByInputIndex.push(rootRow); + + if (rootRow.type !== "group") { + sortedValues(rootRow.values).forEach((value, valueIndex) => { + valuesByInputPath[`features.${inputIndex}.values.${valueIndex}`] = value; + }); + return; + } + + const childRows = sortedRows( + input.rows.filter( + (row) => row.parentId === rootRow.id && row.type === "attribute", + ), + ); + + childRows.forEach((childRow, childIndex) => { + const childInputIndex = features.length; + const childItem = createFeatureSyncItem({ + row: childRow, + index: [rootIndex, childIndex], + inputIndex: childInputIndex, + usedFeatureSlugs, + }); + features.push(childItem); + featureRowsByInputIndex.push(childRow); + + sortedValues(childRow.values).forEach((value, valueIndex) => { + valuesByInputPath[ + `features.${childInputIndex}.values.${valueIndex}` + ] = value; + }); + }); + }); + + return { + input: { + productId: input.productId, + features, + }, + featureRowsByInputIndex, + valuesByInputPath, + }; +} + +export function attributeEditorRowsToProductFeaturesSyncInput(input: { + productId: string; + rows: AttributeEditorRow[]; +}): ApiProductFeaturesSyncInput { + return buildProductFeaturesSyncDraft(input).input; +} + +export function validateAttributeEditorRows(input: { + productId: string; + rows: AttributeEditorRow[]; +}): ApiGenericUserError[] { + const errors: ApiGenericUserError[] = []; + const rowIndexById = getRowInputIndexMap(input.rows); + const featureRowIds = new Set(); + const featureApiIds = new Set(); + const valueIds = new Set(); + const valueApiIds = new Set(); + const featureSlugs = new Map(); + const rootPositions = new Map(); + const childPositionsByParent = new Map>(); + const rowById = new Map(input.rows.map((row) => [row.id, row])); + + if (!input.productId) { + errors.push(makeUserError("Product ID is required.", ["productId"])); + } + + input.rows.forEach((row) => { + const featureIndex = rowIndexById.get(row.id) ?? 0; + const name = row.name.trim(); + const featureSlug = toFeatureSlug(name, `feature-${featureIndex + 1}`); + + if (featureRowIds.has(row.id)) { + errors.push( + makeUserError("Feature row IDs must be unique.", [ + "features", + featureIndex, + "index", + ]), + ); + } + featureRowIds.add(row.id); + + if (row.apiId) { + if (featureApiIds.has(row.apiId)) { + errors.push( + makeUserError("Feature API IDs must be unique.", [ + "features", + featureIndex, + "index", + ]), + ); + } + featureApiIds.add(row.apiId); + } + + if (!name) { + errors.push( + makeUserError("Feature name is required.", [ + "features", + featureIndex, + "name", + ]), + ); + } + + if (!isNonNegativeInteger(row.sortIndex)) { + errors.push( + makeUserError("Feature index must be a non-negative integer.", [ + "features", + featureIndex, + "index", + ]), + ); + } + + if (!isValidSlug(featureSlug)) { + errors.push( + makeUserError("Feature slug is invalid.", [ + "features", + featureIndex, + "name", + ]), + ); + } + + const existingFeatureWithSlug = featureSlugs.get(featureSlug); + if (existingFeatureWithSlug && existingFeatureWithSlug.id !== row.id) { + errors.push( + makeUserError("Feature slugs must be unique within the product.", [ + "features", + featureIndex, + "name", + ]), + ); + } else { + featureSlugs.set(featureSlug, row); + } + + if (row.parentId === null) { + const existingRootPosition = rootPositions.get(row.sortIndex); + if (existingRootPosition && existingRootPosition.id !== row.id) { + errors.push( + makeUserError("Root feature positions must be unique.", [ + "features", + featureIndex, + "index", + ]), + ); + } else { + rootPositions.set(row.sortIndex, row); + } + } else { + const parentPositions = + childPositionsByParent.get(row.parentId) ?? new Map(); + const existingChildPosition = parentPositions.get(row.sortIndex); + + if (existingChildPosition && existingChildPosition.id !== row.id) { + errors.push( + makeUserError("Child feature positions must be unique.", [ + "features", + featureIndex, + "index", + ]), + ); + } else { + parentPositions.set(row.sortIndex, row); + childPositionsByParent.set(row.parentId, parentPositions); + } + } + + if (row.type === "group") { + if (row.parentId !== null) { + errors.push( + makeUserError("Groups must be root rows.", [ + "features", + featureIndex, + "index", + ]), + ); + } + + if (row.values.length > 0) { + errors.push( + makeUserError("Groups cannot contain values.", [ + "features", + featureIndex, + "index", + ]), + ); + } + } + + if (row.type === "attribute" && row.parentId !== null) { + const parent = rowById.get(row.parentId); + + if (!parent) { + errors.push( + makeUserError("Attribute parent group was not found.", [ + "features", + featureIndex, + "index", + ]), + ); + } else if (parent.type !== "group") { + errors.push( + makeUserError("Attribute parent must be a group.", [ + "features", + featureIndex, + "index", + ]), + ); + } else if (parent.parentId !== null || Number(row.level) > 1) { + errors.push( + makeUserError("Attributes can only be nested one level deep.", [ + "features", + featureIndex, + "index", + ]), + ); + } + } + + if (row.apiType && row.apiType !== row.type) { + errors.push( + makeUserError("Existing feature type cannot be changed.", [ + "features", + featureIndex, + "index", + ]), + ); + } + + const valueSlugs = new Map(); + const valuePositions = new Map(); + sortedValues(row.values).forEach((value, valueIndex) => { + const valueName = value.name.trim(); + const valueSlug = toValueSlug(valueName, `value-${valueIndex + 1}`); + + if (valueIds.has(value.id)) { + errors.push( + makeUserError("Value IDs must be unique.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } + valueIds.add(value.id); + + if (!isNonNegativeInteger(value.sortIndex)) { + errors.push( + makeUserError("Value index must be a non-negative integer.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } + + const existingValuePosition = valuePositions.get(value.sortIndex); + if (existingValuePosition && existingValuePosition.id !== value.id) { + errors.push( + makeUserError("Value positions must be unique within an attribute.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } else { + valuePositions.set(value.sortIndex, value); + } + + if (value.apiId) { + if (valueApiIds.has(value.apiId)) { + errors.push( + makeUserError("Value API IDs must be unique.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } + valueApiIds.add(value.apiId); + } + + if (!valueName) { + errors.push( + makeUserError("Value name is required.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } + + if (!isValidSlug(valueSlug)) { + errors.push( + makeUserError("Value slug is invalid.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } + + const existingValueWithSlug = valueSlugs.get(valueSlug); + if (existingValueWithSlug && existingValueWithSlug.id !== value.id) { + errors.push( + makeUserError("Value slugs must be unique within an attribute.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } else { + valueSlugs.set(valueSlug, value); + } + + if (!row.apiId && value.apiId) { + errors.push( + makeUserError("New features cannot contain existing value IDs.", [ + "features", + featureIndex, + "values", + valueIndex, + "name", + ]), + ); + } + }); + }); + + return errors; +} diff --git a/admin/src/domains/inventory/products/mappers/product-option-errors.mapper.ts b/admin/src/domains/inventory/products/mappers/product-option-errors.mapper.ts new file mode 100644 index 000000000..32abc72cc --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-option-errors.mapper.ts @@ -0,0 +1,83 @@ +import type { ApiGenericUserError } from "@/graphql/types"; +import type { + OptionEditorGroup, + OptionEditorValue, +} from "../modals/edit-options-modal/types"; +import type { ProductOptionsSyncDraft } from "./product-options.mapper"; + +export interface ProductOptionEditorErrorMap { + groups: Record; + values: Record; + modal: string[]; +} + +function formatFieldPath(field: string[] | null | undefined): string | null { + if (!field || field.length === 0) { + return null; + } + + return field.join("."); +} + +export function formatProductOptionUserError( + error: ApiGenericUserError, +): string { + const fieldPath = formatFieldPath(error.field); + + return fieldPath ? `${fieldPath}: ${error.message}` : error.message; +} + +export function formatProductOptionUserErrors( + errors: ApiGenericUserError[], +): string[] { + return errors.map(formatProductOptionUserError); +} + +function pushError( + target: Record, + key: string, + message: string, +): void { + target[key] = [...(target[key] ?? []), message]; +} + +export function mapProductOptionUserErrorsToEditorErrors(input: { + errors: ApiGenericUserError[]; + draft: ProductOptionsSyncDraft; +}): ProductOptionEditorErrorMap { + const errorMap: ProductOptionEditorErrorMap = { + groups: {}, + values: {}, + modal: [], + }; + + input.errors.forEach((error) => { + const field = error.field ?? []; + const optionIndex = Number(field[1]); + const valueIndex = Number(field[3]); + const group: OptionEditorGroup | undefined = Number.isInteger(optionIndex) + ? input.draft.optionGroupsByInputIndex[optionIndex] + : undefined; + + if (!group) { + errorMap.modal.push(formatProductOptionUserError(error)); + return; + } + + if (field[2] === "values" && Number.isInteger(valueIndex)) { + const value: OptionEditorValue | undefined = + input.draft.valuesByInputPath[ + `options.${optionIndex}.values.${valueIndex}` + ]; + + if (value) { + pushError(errorMap.values, value.id, error.message); + return; + } + } + + pushError(errorMap.groups, group.id, error.message); + }); + + return errorMap; +} diff --git a/admin/src/domains/inventory/products/mappers/product-options.mapper.ts b/admin/src/domains/inventory/products/mappers/product-options.mapper.ts new file mode 100644 index 000000000..77f7e3948 --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-options.mapper.ts @@ -0,0 +1,706 @@ +import { slugify } from "transliteration/dist/node/src/node/index.js"; +import { + OptionDisplayType, + SwatchType, + type ApiGenericUserError, + type ApiProductOption, + type ApiProductOptionSwatch, + type ApiProductOptionSwatchInput, + type ApiProductOptionSyncItemInput, + type ApiProductOptionValueSyncInput, + type ApiProductOptionsSyncInput, +} from "@/graphql/types"; +import type { + OptionEditorGroup, + OptionEditorSwatch, + OptionEditorValue, +} from "../modals/edit-options-modal/types"; + +const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; +const DATA_URL_PATTERN = /^data:/i; +const VALID_DISPLAY_TYPES = new Set(Object.values(OptionDisplayType)); +const VALID_SWATCH_TYPES = new Set(Object.values(SwatchType)); + +type UserErrorField = Array; + +export interface ProductOptionsSyncDraft { + input: ApiProductOptionsSyncInput; + optionGroupsByInputIndex: OptionEditorGroup[]; + valuesByInputPath: Record; +} + +function createTemporaryId(prefix: string): string { + const uuid = + globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; + + return `${prefix}-${uuid}`; +} + +export function createTemporaryOptionId(): string { + return createTemporaryId("tmp-option"); +} + +export function createTemporaryOptionValueId(): string { + return createTemporaryId("tmp-option-value"); +} + +function isNonNegativeInteger(value: number): boolean { + return Number.isInteger(value) && value >= 0; +} + +function isValidSlug(slug: string): boolean { + return SLUG_PATTERN.test(slug); +} + +function isValidDisplayType(displayType: OptionDisplayType): boolean { + return VALID_DISPLAY_TYPES.has(displayType); +} + +function isValidSwatchType(swatchType: SwatchType): boolean { + return VALID_SWATCH_TYPES.has(swatchType); +} + +function isExistingApiFileId(fileId: string | null | undefined): fileId is string { + return Boolean(fileId && !DATA_URL_PATTERN.test(fileId)); +} + +function makeUserError( + message: string, + field?: UserErrorField, + code = "VALIDATION_ERROR", +): ApiGenericUserError { + return { + code, + field: field?.map(String), + message, + }; +} + +function normalizeSlug(value: string, fallback: string): string { + const fallbackSlug = + fallback + .toLocaleLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") || "item"; + + const slug = + slugify(value) + .toLocaleLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") || fallbackSlug; + + if (isValidSlug(slug)) { + return slug; + } + + return isValidSlug(fallbackSlug) ? fallbackSlug : "item"; +} + +export function toOptionSlug(name: string, fallback: string): string { + return normalizeSlug(name, fallback); +} + +export function toOptionValueSlug(name: string, fallback: string): string { + return normalizeSlug(name, fallback); +} + +function toUniqueSlug(slug: string, used: Set): string { + const base = isValidSlug(slug) ? slug : "item"; + let candidate = base; + let suffix = 2; + + while (used.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + + used.add(candidate); + return candidate; +} + +function sortedGroups(groups: OptionEditorGroup[]): OptionEditorGroup[] { + return [...groups].sort((left, right) => { + if (left.sortIndex !== right.sortIndex) { + return left.sortIndex - right.sortIndex; + } + + return left.id.localeCompare(right.id); + }); +} + +function sortedValues(values: OptionEditorValue[]): OptionEditorValue[] { + return [...values].sort((left, right) => { + if (left.sortIndex !== right.sortIndex) { + return left.sortIndex - right.sortIndex; + } + + return left.id.localeCompare(right.id); + }); +} + +function apiSwatchToEditorSwatch( + swatch: ApiProductOptionSwatch | null | undefined, +): OptionEditorSwatch | null { + if (!swatch) { + return null; + } + + return { + swatchType: swatch.swatchType, + colorOne: swatch.colorOne, + colorTwo: swatch.colorTwo, + fileId: swatch.swatchType === SwatchType.Image ? swatch.file?.id ?? null : null, + fileUrl: swatch.swatchType === SwatchType.Image ? swatch.file?.url ?? null : null, + metadata: swatch.metadata, + }; +} + +export function apiProductOptionsToOptionEditorGroups( + options: ApiProductOption[], +): OptionEditorGroup[] { + return options.map((option, optionIndex) => ({ + id: option.id, + apiId: option.id, + name: option.name, + slug: option.slug, + displayType: option.displayType, + sortIndex: optionIndex, + values: option.values.map((value, valueIndex) => ({ + id: value.id, + apiId: value.id, + apiSwatchId: value.swatch?.id, + name: value.name, + slug: value.slug, + sortIndex: valueIndex, + swatch: apiSwatchToEditorSwatch(value.swatch), + })), + })); +} + +function swatchMetadataInput( + metadata: unknown, +): Record | null | undefined { + if (metadata === null || metadata === undefined) { + return metadata; + } + + return typeof metadata === "object" + ? (metadata as Record) + : undefined; +} + +export function optionEditorSwatchToProductOptionSwatchInput( + swatch: OptionEditorSwatch | null, +): ApiProductOptionSwatchInput | null { + if (!swatch) { + return null; + } + + const input: ApiProductOptionSwatchInput = { + swatchType: swatch.swatchType, + }; + const metadata = swatchMetadataInput(swatch.metadata); + + if (metadata !== undefined) { + input.metadata = metadata; + } + + if (swatch.swatchType === SwatchType.Color) { + input.colorOne = swatch.colorOne ?? null; + return input; + } + + if (swatch.swatchType === SwatchType.Gradient) { + input.colorOne = swatch.colorOne ?? null; + input.colorTwo = swatch.colorTwo ?? null; + return input; + } + + if (swatch.swatchType === SwatchType.Image) { + input.fileId = isExistingApiFileId(swatch.fileId) ? swatch.fileId : null; + } + + return input; +} + +function getOptionSlug(input: { + group: OptionEditorGroup; + optionIndex: number; + usedSlugs: Set; +}): string { + const preservedSlug = input.group.apiId ? input.group.slug.trim() : ""; + + if (preservedSlug) { + input.usedSlugs.add(preservedSlug); + return preservedSlug; + } + + return toUniqueSlug( + toOptionSlug(input.group.name, `option-${input.optionIndex + 1}`), + input.usedSlugs, + ); +} + +function getValueSlug(input: { + value: OptionEditorValue; + valueIndex: number; + usedSlugs: Set; +}): string { + const preservedSlug = input.value.apiId ? input.value.slug.trim() : ""; + + if (preservedSlug) { + input.usedSlugs.add(preservedSlug); + return preservedSlug; + } + + return toUniqueSlug( + toOptionValueSlug(input.value.name, `value-${input.valueIndex + 1}`), + input.usedSlugs, + ); +} + +function createValueSyncInput(input: { + value: OptionEditorValue; + groupDisplayType: OptionDisplayType; + sortIndex: number; + slug: string; +}): ApiProductOptionValueSyncInput { + const valueInput: ApiProductOptionValueSyncInput = { + name: input.value.name.trim(), + slug: input.slug, + sortIndex: input.sortIndex, + swatch: + input.groupDisplayType === OptionDisplayType.Swatch + ? optionEditorSwatchToProductOptionSwatchInput(input.value.swatch) + : null, + }; + + if (input.value.apiId) { + valueInput.id = input.value.apiId; + } + + return valueInput; +} + +export function buildProductOptionsSyncDraft(input: { + productId: string; + groups: OptionEditorGroup[]; +}): ProductOptionsSyncDraft { + const options: ApiProductOptionSyncItemInput[] = []; + const optionGroupsByInputIndex: OptionEditorGroup[] = []; + const valuesByInputPath: Record = {}; + const usedOptionSlugs = new Set(); + + sortedGroups(input.groups).forEach((group, optionIndex) => { + const usedValueSlugs = new Set(); + const values = sortedValues(group.values).map((value, valueIndex) => { + valuesByInputPath[`options.${optionIndex}.values.${valueIndex}`] = value; + + return createValueSyncInput({ + value, + groupDisplayType: group.displayType, + sortIndex: valueIndex, + slug: getValueSlug({ + value, + valueIndex, + usedSlugs: usedValueSlugs, + }), + }); + }); + + const optionInput: ApiProductOptionSyncItemInput = { + displayType: group.displayType, + name: group.name.trim(), + slug: getOptionSlug({ + group, + optionIndex, + usedSlugs: usedOptionSlugs, + }), + sortIndex: optionIndex, + values, + }; + + if (group.apiId) { + optionInput.id = group.apiId; + } + + options.push(optionInput); + optionGroupsByInputIndex.push(group); + }); + + return { + input: { + productId: input.productId, + options, + }, + optionGroupsByInputIndex, + valuesByInputPath, + }; +} + +export function optionEditorGroupsToProductOptionsSyncInput(input: { + productId: string; + groups: OptionEditorGroup[]; +}): ApiProductOptionsSyncInput { + return buildProductOptionsSyncDraft(input).input; +} + +function getOptionIndexById( + groups: OptionEditorGroup[], +): Map { + return new Map(sortedGroups(groups).map((group, index) => [group.id, index])); +} + +function getValueIndexById( + values: OptionEditorValue[], +): Map { + return new Map(sortedValues(values).map((value, index) => [value.id, index])); +} + +function validateSwatch(input: { + errors: ApiGenericUserError[]; + optionIndex: number; + valueIndex: number; + swatch: OptionEditorSwatch | null; +}): void { + if (!input.swatch) { + input.errors.push( + makeUserError("Swatch is required for swatch display options.", [ + "options", + input.optionIndex, + "values", + input.valueIndex, + "swatch", + ]), + ); + return; + } + + if (!isValidSwatchType(input.swatch.swatchType)) { + input.errors.push( + makeUserError("Swatch type is invalid.", [ + "options", + input.optionIndex, + "values", + input.valueIndex, + "swatch", + ]), + ); + return; + } + + if ( + input.swatch.swatchType === SwatchType.Color && + !input.swatch.colorOne + ) { + input.errors.push( + makeUserError("Color swatches require a primary color.", [ + "options", + input.optionIndex, + "values", + input.valueIndex, + "swatch", + ]), + ); + } + + if ( + input.swatch.swatchType === SwatchType.Gradient && + (!input.swatch.colorOne || !input.swatch.colorTwo) + ) { + input.errors.push( + makeUserError("Gradient swatches require two colors.", [ + "options", + input.optionIndex, + "values", + input.valueIndex, + "swatch", + ]), + ); + } + + if ( + input.swatch.swatchType === SwatchType.Image && + !isExistingApiFileId(input.swatch.fileId) + ) { + input.errors.push( + makeUserError("Image swatches require an existing uploaded file.", [ + "options", + input.optionIndex, + "values", + input.valueIndex, + "swatch", + ]), + ); + } +} + +export function validateOptionEditorGroups(input: { + productId: string; + groups: OptionEditorGroup[]; +}): ApiGenericUserError[] { + const errors: ApiGenericUserError[] = []; + const optionIndexById = getOptionIndexById(input.groups); + const optionRowIds = new Set(); + const optionApiIds = new Set(); + const valueRowIds = new Set(); + const valueApiIds = new Set(); + const optionPositions = new Map(); + + if (!input.productId) { + errors.push(makeUserError("Product ID is required.", ["productId"])); + } + + input.groups.forEach((group) => { + const optionIndex = optionIndexById.get(group.id) ?? 0; + const optionName = group.name.trim(); + + if (optionRowIds.has(group.id)) { + errors.push( + makeUserError("Option row IDs must be unique.", [ + "options", + optionIndex, + "sortIndex", + ]), + ); + } + optionRowIds.add(group.id); + + if (group.apiId) { + if (optionApiIds.has(group.apiId)) { + errors.push( + makeUserError("Option API IDs must be unique.", [ + "options", + optionIndex, + "sortIndex", + ]), + ); + } + optionApiIds.add(group.apiId); + } + + if (!optionName) { + errors.push( + makeUserError("Option name is required.", [ + "options", + optionIndex, + "name", + ]), + ); + } + + if (!isNonNegativeInteger(group.sortIndex)) { + errors.push( + makeUserError("Option sort index must be a non-negative integer.", [ + "options", + optionIndex, + "sortIndex", + ]), + ); + } + + const existingOptionPosition = optionPositions.get(group.sortIndex); + if (existingOptionPosition && existingOptionPosition.id !== group.id) { + errors.push( + makeUserError("Option sort indexes must be unique.", [ + "options", + optionIndex, + "sortIndex", + ]), + ); + } else { + optionPositions.set(group.sortIndex, group); + } + + if (!isValidDisplayType(group.displayType)) { + errors.push( + makeUserError("Option display type is invalid.", [ + "options", + optionIndex, + "displayType", + ]), + ); + } + + if (group.values.length === 0) { + errors.push( + makeUserError("Every option must contain at least one value.", [ + "options", + optionIndex, + "values", + ]), + ); + } + + const valueIndexById = getValueIndexById(group.values); + const valuePositions = new Map(); + + sortedValues(group.values).forEach((value) => { + const valueIndex = valueIndexById.get(value.id) ?? 0; + const valueName = value.name.trim(); + + if (valueRowIds.has(value.id)) { + errors.push( + makeUserError("Value row IDs must be unique.", [ + "options", + optionIndex, + "values", + valueIndex, + "name", + ]), + ); + } + valueRowIds.add(value.id); + + if (value.apiId) { + if (valueApiIds.has(value.apiId)) { + errors.push( + makeUserError("Value API IDs must be unique.", [ + "options", + optionIndex, + "values", + valueIndex, + "name", + ]), + ); + } + valueApiIds.add(value.apiId); + } + + if (!group.apiId && value.apiId) { + errors.push( + makeUserError("New options cannot contain existing value IDs.", [ + "options", + optionIndex, + "values", + valueIndex, + "name", + ]), + ); + } + + if (!valueName) { + errors.push( + makeUserError("Value name is required.", [ + "options", + optionIndex, + "values", + valueIndex, + "name", + ]), + ); + } + + if (!isNonNegativeInteger(value.sortIndex)) { + errors.push( + makeUserError("Value sort index must be a non-negative integer.", [ + "options", + optionIndex, + "values", + valueIndex, + "sortIndex", + ]), + ); + } + + const existingValuePosition = valuePositions.get(value.sortIndex); + if (existingValuePosition && existingValuePosition.id !== value.id) { + errors.push( + makeUserError("Value sort indexes must be unique within an option.", [ + "options", + optionIndex, + "values", + valueIndex, + "sortIndex", + ]), + ); + } else { + valuePositions.set(value.sortIndex, value); + } + + if (group.displayType === OptionDisplayType.Swatch) { + validateSwatch({ + errors, + optionIndex, + valueIndex, + swatch: value.swatch, + }); + } + }); + }); + + const draft = buildProductOptionsSyncDraft(input); + const optionSlugs = new Map(); + + draft.input.options.forEach((option, optionIndex) => { + if (!isValidSlug(option.slug)) { + errors.push( + makeUserError("Option slug is invalid.", [ + "options", + optionIndex, + "slug", + ]), + ); + } + + const existingOptionIndex = optionSlugs.get(option.slug); + if (existingOptionIndex !== undefined && existingOptionIndex !== optionIndex) { + errors.push( + makeUserError("Option slugs must be unique within the product.", [ + "options", + optionIndex, + "slug", + ]), + ); + } else { + optionSlugs.set(option.slug, optionIndex); + } + + const valueSlugs = new Map(); + + option.values.forEach((value, valueIndex) => { + if (!isValidSlug(value.slug)) { + errors.push( + makeUserError("Value slug is invalid.", [ + "options", + optionIndex, + "values", + valueIndex, + "slug", + ]), + ); + } + + const existingValueIndex = valueSlugs.get(value.slug); + if (existingValueIndex !== undefined && existingValueIndex !== valueIndex) { + errors.push( + makeUserError("Value slugs must be unique within an option.", [ + "options", + optionIndex, + "values", + valueIndex, + "slug", + ]), + ); + } else { + valueSlugs.set(value.slug, valueIndex); + } + + if (option.displayType !== OptionDisplayType.Swatch && value.swatch !== null) { + errors.push( + makeUserError("Non-swatch options must not send value swatches.", [ + "options", + optionIndex, + "values", + valueIndex, + "swatch", + ]), + ); + } + }); + }); + + return errors; +} diff --git a/admin/src/domains/inventory/products/mappers/product-variant-editor.mapper.ts b/admin/src/domains/inventory/products/mappers/product-variant-editor.mapper.ts new file mode 100644 index 000000000..649eb7052 --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-variant-editor.mapper.ts @@ -0,0 +1,155 @@ +import type { ApiFile, ApiProductOption, ApiVariant } from "@/graphql/types"; +import type { + IVariantEditorInput, + IVariantEditorRow, +} from "../components/variants/config/types"; +import { + DEFAULT_DIMENSION_UNIT, + DEFAULT_WEIGHT_UNIT, + mapApiDimensionsToVariantFields, + mapApiWeightToVariantFields, +} from "../utils/product-measurements"; + +export interface MapApiVariantsToEditorInputsOptions { + productMediaFiles?: ApiFile[]; +} + +export interface VariantEditorSaveRow { + id: string; + kind?: "existing" | "draft" | "blank"; + clientMutationId?: string; + selectedOptionValueIds: Record; + price: number | null; + compareAtPrice: number | null; + weight: number | null; + weightUnit: string; + length: number | null; + width: number | null; + height: number | null; + dimensionUnit: string; + mediaFileIds: string[]; +} + +function sortVariantMediaFiles( + variant: ApiVariant, + productMediaFiles?: ApiFile[], +): ApiFile[] { + const productMediaOrder = new Map( + (productMediaFiles ?? []).map((file, index) => [file.id, index]), + ); + + return [...variant.media] + .sort((left, right) => { + const leftOrder = productMediaOrder.get(left.file.id); + const rightOrder = productMediaOrder.get(right.file.id); + + if (leftOrder !== undefined || rightOrder !== undefined) { + return (leftOrder ?? Number.MAX_SAFE_INTEGER) - + (rightOrder ?? Number.MAX_SAFE_INTEGER); + } + + return left.sortIndex - right.sortIndex; + }) + .map((media) => media.file); +} + +export function mapApiVariantToEditorInput( + variant: ApiVariant, + productOptions: ApiProductOption[], + options?: MapApiVariantsToEditorInputsOptions, +): IVariantEditorInput { + const optionsById = new Map( + productOptions.map((option) => [option.id, option]), + ); + const sortedMediaFiles = sortVariantMediaFiles( + variant, + options?.productMediaFiles, + ); + + return { + ...mapApiWeightToVariantFields(variant.weight), + ...mapApiDimensionsToVariantFields(variant.dimensions), + id: variant.id, + kind: "existing", + title: variant.title ?? variant.handle, + imageUrl: sortedMediaFiles[0]?.url ?? null, + media: sortedMediaFiles, + options: variant.selectedOptions.map((selectedOption) => { + const option = optionsById.get(selectedOption.optionId); + const value = option?.values.find( + (candidate) => candidate.id === selectedOption.optionValueId, + ); + + return { + optionId: selectedOption.optionId, + optionValueId: selectedOption.optionValueId, + name: option?.name ?? "Option", + value: value?.name ?? "Unknown option", + }; + }), + selectedOptionValueIds: Object.fromEntries( + productOptions.map((option) => { + const selectedOption = variant.selectedOptions.find( + (candidate) => candidate.optionId === option.id, + ); + + return [option.id, selectedOption?.optionValueId ?? null]; + }), + ), + price: variant.price?.amountMinor ?? null, + compareAtPrice: variant.price?.compareAtMinor ?? null, + }; +} + +export function mapApiVariantsToEditorInputs( + variants: ApiVariant[], + productOptions: ApiProductOption[], + options?: MapApiVariantsToEditorInputsOptions, +): IVariantEditorInput[] { + return variants.map((variant) => + mapApiVariantToEditorInput(variant, productOptions, options), + ); +} + +export function mapVariantEditorInputsToRows( + inputs: IVariantEditorInput[], +): IVariantEditorRow[] { + return inputs.map((input) => ({ + id: input.id, + kind: input.kind, + clientMutationId: input.clientMutationId, + title: input.title, + imageUrl: input.imageUrl ?? null, + media: input.media ?? [], + options: input.options ?? [], + selectedOptionValueIds: input.selectedOptionValueIds ?? {}, + price: input.price ?? null, + compareAtPrice: input.compareAtPrice ?? null, + weight: input.weight ?? null, + weightUnit: input.weightUnit ?? DEFAULT_WEIGHT_UNIT, + length: input.length ?? null, + width: input.width ?? null, + height: input.height ?? null, + dimensionUnit: input.dimensionUnit ?? DEFAULT_DIMENSION_UNIT, + })); +} + +export function getVariantEditorRowsForSave( + rows: IVariantEditorRow[], +): VariantEditorSaveRow[] { + return rows.map((row) => ({ + id: row.id, + kind: row.kind, + clientMutationId: row.clientMutationId, + selectedOptionValueIds: row.selectedOptionValueIds, + price: row.price, + compareAtPrice: row.compareAtPrice, + weight: row.weight, + weightUnit: row.weightUnit, + length: row.length, + width: row.width, + height: row.height, + dimensionUnit: row.dimensionUnit, + mediaFileIds: row.media.map((file) => file.id), + })); +} diff --git a/admin/src/domains/inventory/products/mappers/product-variant-options.mapper.ts b/admin/src/domains/inventory/products/mappers/product-variant-options.mapper.ts new file mode 100644 index 000000000..c09e0d78c --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-variant-options.mapper.ts @@ -0,0 +1,206 @@ +import type { + ApiProductOption, + ApiProductUpdateInput, + ApiVariant, +} from "@/graphql/types"; +import { VariantOperationAction } from "@/graphql/types"; +import type { + IVariantEditorRow, + VariantOptionRowsValidationResult, +} from "../components/variants/config/types"; + +const sortProductOptions = (productOptions: ApiProductOption[]) => + [...productOptions].sort((a, b) => a.sortIndex - b.sortIndex); + +const getVariantTitle = (variant: ApiVariant) => + variant.title?.trim() || variant.handle || variant.id; + +export function buildCombinationKey( + row: Pick, + productOptions: ApiProductOption[], +): string | null { + const parts: string[] = []; + + for (const option of sortProductOptions(productOptions)) { + const valueId = row.selectedOptionValueIds[option.id]; + if (!valueId) { + return null; + } + parts.push(`${option.id}:${valueId}`); + } + + return parts.join("|"); +} + +export function apiVariantsToVariantOptionRows( + variants: ApiVariant[], + productOptions: ApiProductOption[], +): IVariantEditorRow[] { + return variants.map((variant) => { + const selectedOptionValueIds = Object.fromEntries( + productOptions.map((option) => { + const selectedOption = variant.selectedOptions.find( + (optionValue) => optionValue.optionId === option.id, + ); + + return [option.id, selectedOption?.optionValueId ?? null]; + }), + ); + + return { + id: variant.id, + title: getVariantTitle(variant), + imageUrl: null, + media: [], + options: [], + selectedOptionValueIds, + price: null, + compareAtPrice: null, + weight: null, + weightUnit: "g", + length: null, + width: null, + height: null, + dimensionUnit: "mm", + }; + }); +} + +export function validateVariantOptionRows( + rows: IVariantEditorRow[], + productOptions: ApiProductOption[], +): VariantOptionRowsValidationResult { + const maxCombinations = productOptions.length === 0 + ? 1 + : productOptions.reduce( + (count, option) => count * Math.max(option.values.length, 1), + 1, + ); + const valueIdsByOptionId = new Map( + productOptions.map((option) => [ + option.id, + new Set(option.values.map((value) => value.id)), + ]), + ); + const incompleteRowIds = new Set(); + const invalidRowIds = new Set(); + const keyToRowIds = new Map(); + + for (const row of rows) { + let invalid = false; + let incomplete = false; + + for (const option of sortProductOptions(productOptions)) { + const valueId = row.selectedOptionValueIds[option.id]; + if (!valueId) { + incomplete = true; + continue; + } + + if (!valueIdsByOptionId.get(option.id)?.has(valueId)) { + invalid = true; + } + } + + if (incomplete) { + incompleteRowIds.add(row.id); + } + + if (invalid) { + invalidRowIds.add(row.id); + } + + const combinationKey = + incomplete || invalid ? null : buildCombinationKey(row, productOptions); + if (combinationKey) { + keyToRowIds.set(combinationKey, [ + ...(keyToRowIds.get(combinationKey) ?? []), + row.id, + ]); + } + } + + const duplicateRowIds = new Set(); + for (const rowIds of keyToRowIds.values()) { + if (rowIds.length > 1) { + rowIds.forEach((rowId) => duplicateRowIds.add(rowId)); + } + } + + const validatedRows = rows.map((row) => { + const duplicateKey = buildCombinationKey(row, productOptions); + const messages: string[] = []; + + if (incompleteRowIds.has(row.id)) { + messages.push("Select every option value."); + } + if (invalidRowIds.has(row.id)) { + messages.push("One or more selected values no longer belong to their option."); + } + if (duplicateRowIds.has(row.id)) { + messages.push("This option combination is duplicated."); + } + + return { + ...row, + duplicateKey: duplicateRowIds.has(row.id) ? duplicateKey : null, + validationMessage: messages.length > 0 ? messages.join(" ") : null, + }; + }); + + const messages: string[] = []; + if (rows.length > maxCombinations) { + messages.push( + `Variant rows exceed the ${maxCombinations} possible option combination(s).`, + ); + } + if (incompleteRowIds.size > 0) { + messages.push(`${incompleteRowIds.size} variant row(s) have incomplete options.`); + } + if (invalidRowIds.size > 0) { + messages.push(`${invalidRowIds.size} variant row(s) have invalid option values.`); + } + if (duplicateRowIds.size > 0) { + messages.push(`${duplicateRowIds.size} variant row(s) duplicate another combination.`); + } + + return { + rows: validatedRows, + hasErrors: messages.length > 0, + duplicateRowIds: [...duplicateRowIds], + incompleteRowIds: [...incompleteRowIds], + invalidRowIds: [...invalidRowIds], + messages, + }; +} + +export function variantOptionRowsToProductUpdateInput( + rows: IVariantEditorRow[], + originalRows: IVariantEditorRow[], + productOptions: ApiProductOption[], +): ApiProductUpdateInput { + const originalKeysByRowId = new Map( + originalRows.map((row) => [row.id, buildCombinationKey(row, productOptions)]), + ); + const changedRows = rows.filter( + (row) => + row.kind !== "draft" && + row.kind !== "blank" && + !row.id.startsWith("draft:") && + !row.id.startsWith("blank:") && + buildCombinationKey(row, productOptions) !== originalKeysByRowId.get(row.id), + ); + + return { + variants: changedRows.map((row) => ({ + action: VariantOperationAction.Update, + variantId: row.id, + options: { + set: sortProductOptions(productOptions).map((option) => ({ + optionId: option.id, + optionValueId: row.selectedOptionValueIds[option.id] as string, + })), + }, + })), + }; +} diff --git a/admin/src/domains/inventory/products/mappers/product-variant-update.mapper.ts b/admin/src/domains/inventory/products/mappers/product-variant-update.mapper.ts new file mode 100644 index 000000000..b64c528d6 --- /dev/null +++ b/admin/src/domains/inventory/products/mappers/product-variant-update.mapper.ts @@ -0,0 +1,382 @@ +import type { + ApiProductOption, + ApiVariant, + ApiVariantOperationInput, + CurrencyCode, +} from "@/graphql/types"; +import { VariantOperationAction } from "@/graphql/types"; +import type { VariantEditorSaveRow } from "./product-variant-editor.mapper"; + +export interface PrepareChangedVariantUpdateOperationsParams { + rows: VariantEditorSaveRow[]; + variants: ApiVariant[]; + defaultCurrency: CurrencyCode | null | undefined; + includePricing?: boolean; + includeShipping?: boolean; + includeMedia?: boolean; +} + +export interface PrepareDraftVariantCreateOperationsParams { + rows: VariantEditorSaveRow[]; + productOptions: ApiProductOption[]; + defaultCurrency: CurrencyCode | null | undefined; + includePricing?: boolean; + includeShipping?: boolean; + includeMedia?: boolean; +} + +function parseRequiredMinorUnit(value: unknown): number { + const parsed = Number(value); + + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new Error("Prices must be valid whole minor-unit numbers."); + } + + return parsed; +} + +function parseOptionalMinorUnit(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + return parseRequiredMinorUnit(value); +} + +function parseOptionalInteger( + value: unknown, + label: string, +): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const parsed = Number(value); + + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new Error(`${label} must be a whole number.`); + } + + return parsed; +} + +function parsePositiveInteger(value: unknown, label: string): number { + const parsed = parseOptionalInteger(value, label); + + if (parsed === null) { + throw new Error(`${label} is required.`); + } + + if (parsed <= 0) { + throw new Error(`${label} must be greater than zero.`); + } + + return parsed; +} + +function getOrCreateUpdate( + updatesByVariantId: Map, + variantId: string, +): ApiVariantOperationInput { + const existing = updatesByVariantId.get(variantId); + + if (existing) { + return existing; + } + + const update: ApiVariantOperationInput = { + action: VariantOperationAction.Update, + variantId, + }; + + updatesByVariantId.set(variantId, update); + + return update; +} + +function applyPricingUpdate( + update: ApiVariantOperationInput, + row: VariantEditorSaveRow, + variant: ApiVariant, + defaultCurrency: CurrencyCode | null | undefined, +) { + const amountMinor = parseOptionalMinorUnit(row.price); + const compareAtMinor = parseOptionalMinorUnit(row.compareAtPrice); + const originalAmountMinor = variant.price?.amountMinor ?? null; + const originalCompareAtMinor = variant.price?.compareAtMinor ?? null; + + if ( + amountMinor === originalAmountMinor && + compareAtMinor === originalCompareAtMinor + ) { + return; + } + + applyPricingOperation(update, amountMinor, compareAtMinor, defaultCurrency); +} + +function applyPricingOperation( + operation: ApiVariantOperationInput, + amountMinor: number | null, + compareAtMinor: number | null, + defaultCurrency: CurrencyCode | null | undefined, +) { + if (amountMinor === null && compareAtMinor === null) { + return; + } + + if (amountMinor === null) { + throw new Error("Price is required to save variant pricing."); + } + + if (!defaultCurrency) { + throw new Error("Store default currency is required to save new prices."); + } + + operation.pricing = { + currency: defaultCurrency, + amountMinor, + compareAtMinor, + }; +} + +function applyShippingUpdate( + update: ApiVariantOperationInput, + row: VariantEditorSaveRow, + variant: ApiVariant, +) { + const weight = parseOptionalInteger(row.weight, "Weight"); + const originalWeight = variant.weight?.value ?? null; + const weightChanged = weight !== originalWeight; + const originalLength = variant.dimensions?.length ?? null; + const originalWidth = variant.dimensions?.width ?? null; + const originalHeight = variant.dimensions?.height ?? null; + const length = parseOptionalInteger(row.length, "Length"); + const width = parseOptionalInteger(row.width, "Width"); + const height = parseOptionalInteger(row.height, "Height"); + const dimensionsChanged = + length !== originalLength || + width !== originalWidth || + height !== originalHeight; + + if (weightChanged && weight === null && originalWeight !== null) { + throw new Error("Clearing existing weight is not supported."); + } + + if (dimensionsChanged) { + applyDimensionsOperation(update, length, width, height); + } + + if (weightChanged && weight !== null) { + update.weight = parsePositiveInteger(weight, "Weight"); + } +} + +function applyDimensionsOperation( + operation: ApiVariantOperationInput, + length: number | null, + width: number | null, + height: number | null, +) { + const hasDimension = length !== null || width !== null || height !== null; + + if (!hasDimension) { + return; + } + + if (length === null || width === null || height === null) { + throw new Error( + "Length, width, and height are required to save dimensions.", + ); + } + + operation.dimensions = { + length: parsePositiveInteger(length, "Length"), + width: parsePositiveInteger(width, "Width"), + height: parsePositiveInteger(height, "Height"), + }; +} + +function areSameFileIdSet( + left: readonly string[], + right: readonly string[], +): boolean { + if (left.length !== right.length) { + return false; + } + + const rightSet = new Set(right); + + return left.every((fileId) => rightSet.has(fileId)); +} + +function applyMediaUpdate( + update: ApiVariantOperationInput, + row: VariantEditorSaveRow, + variant: ApiVariant, +) { + const originalFileIds = [...variant.media] + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((media) => media.file.id); + + if (areSameFileIdSet(row.mediaFileIds, originalFileIds)) { + return; + } + + update.media = { + fileIds: row.mediaFileIds, + }; +} + +function applyMaterializedRowFields( + operation: ApiVariantOperationInput, + row: VariantEditorSaveRow, + defaultCurrency: CurrencyCode | null | undefined, + options: { + includePricing: boolean; + includeShipping: boolean; + includeMedia: boolean; + }, +) { + if (options.includePricing) { + applyPricingOperation( + operation, + parseOptionalMinorUnit(row.price), + parseOptionalMinorUnit(row.compareAtPrice), + defaultCurrency, + ); + } + + if (options.includeShipping) { + const weight = parseOptionalInteger(row.weight, "Weight"); + const length = parseOptionalInteger(row.length, "Length"); + const width = parseOptionalInteger(row.width, "Width"); + const height = parseOptionalInteger(row.height, "Height"); + + if (weight !== null) { + operation.weight = parsePositiveInteger(weight, "Weight"); + } + + applyDimensionsOperation(operation, length, width, height); + } + + if (options.includeMedia && row.mediaFileIds.length > 0) { + operation.media = { + fileIds: row.mediaFileIds, + }; + } +} + +function hasVariantOperationFields(operation: ApiVariantOperationInput): boolean { + return Boolean( + operation.pricing || + operation.dimensions || + operation.weight !== undefined || + operation.media || + operation.options || + operation.inventory, + ); +} + +export function prepareChangedVariantUpdateOperations({ + rows, + variants, + defaultCurrency, + includePricing = true, + includeShipping = true, + includeMedia = true, +}: PrepareChangedVariantUpdateOperationsParams): ApiVariantOperationInput[] { + const variantsById = new Map( + variants.map((variant) => [variant.id, variant]), + ); + const updatesByVariantId = new Map(); + + for (const row of rows) { + if (row.kind === "draft" || row.kind === "blank") { + continue; + } + + const update = getOrCreateUpdate(updatesByVariantId, row.id); + const variant = variantsById.get(row.id); + + if (variant) { + if (includePricing) { + applyPricingUpdate(update, row, variant, defaultCurrency); + } + + if (includeShipping) { + applyShippingUpdate(update, row, variant); + } + + if (includeMedia) { + applyMediaUpdate(update, row, variant); + } + } else { + applyMaterializedRowFields(update, row, defaultCurrency, { + includePricing, + includeShipping, + includeMedia, + }); + } + + if (!hasVariantOperationFields(update)) { + updatesByVariantId.delete(row.id); + } + } + + return Array.from(updatesByVariantId.values()); +} + +export function prepareDraftVariantCreateOperations({ + rows, + productOptions, + defaultCurrency, + includePricing = true, + includeShipping = true, + includeMedia = true, +}: PrepareDraftVariantCreateOperationsParams): ApiVariantOperationInput[] { + const sortedProductOptions = [...productOptions].sort( + (left, right) => left.sortIndex - right.sortIndex, + ); + + return rows + .filter((row) => row.kind === "draft") + .map((row) => { + if (!row.clientMutationId) { + throw new Error("Draft variant is missing clientMutationId."); + } + + const operation: ApiVariantOperationInput = { + action: VariantOperationAction.Create, + clientMutationId: row.clientMutationId, + options: { + set: sortedProductOptions.map((option) => { + const optionValueId = row.selectedOptionValueIds[option.id]; + + if (!optionValueId) { + throw new Error("Draft variants must select every option value."); + } + + if (!option.values.some((value) => value.id === optionValueId)) { + throw new Error( + "Draft variant selected option values are invalid.", + ); + } + + return { + optionId: option.id, + optionValueId, + }; + }), + }, + }; + + applyMaterializedRowFields(operation, row, defaultCurrency, { + includePricing, + includeShipping, + includeMedia, + }); + + return operation; + }); +} diff --git a/admin/src/domains/inventory/products/modals.ts b/admin/src/domains/inventory/products/modals.ts index 425d7a6bb..fb3488643 100644 --- a/admin/src/domains/inventory/products/modals.ts +++ b/admin/src/domains/inventory/products/modals.ts @@ -1,11 +1,22 @@ -import { createModalStackHook } from '@/layouts/modals'; -import type { IModalStackPayload } from '@/layouts/modals'; +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; import type { OutputData } from '@editorjs/editorjs'; -import type { RenderedContent } from '@/ui-kit/block-editor'; -import type { IProduct } from "@/mocks/products/types"; -import type { ApiFile } from "@/graphql/types"; -import type { ApiVariantPrice } from './components/pricing'; -import type { VariantColumnField } from './components/variants/config'; +import type { RenderedContent } from '@/ui-kit/editor/renderers'; +import type { + ApiFile, + ApiGenericUserError, + ApiOperationResult, + ApiProduct, + ApiProductFeature, + ApiProductOption, + ApiProductUpdateInput, + ApiVariantOperationInput, + ApiTag, + ApiVariant, + CurrencyCode, +} from "@/graphql/types"; +import type { VariantColumnField } from './components/variants/config/types'; +import type { VariantEditorSaveRow } from "./mappers/product-variant-editor.mapper"; // ============================================================================ // Modal Types @@ -18,14 +29,12 @@ export const PRODUCT_EDIT_DESCRIPTION_MODAL_TYPE = 'product-edit-description'; export const PRODUCT_AI_WRITER_MODAL_TYPE = 'product-ai-writer'; export const PRODUCT_PRICE_HISTORY_MODAL_TYPE = 'product-price-history'; export const PRODUCT_EDIT_VARIANT_PRICING_MODAL_TYPE = 'product-edit-variant-pricing'; -export const PRODUCT_EDIT_VARIANT_INVENTORY_MODAL_TYPE = 'product-edit-variant-inventory'; export const PRODUCT_EDIT_MEDIA_MODAL_TYPE = 'product-edit-media'; export const PRODUCT_EDIT_OPTIONS_MODAL_TYPE = 'product-edit-options'; export const PRODUCT_EDIT_ATTRIBUTES_MODAL_TYPE = 'product-edit-attributes'; export const PRODUCT_EDIT_SEO_MODAL_TYPE = 'product-edit-seo'; export const PRODUCT_EDIT_VARIANT_SHIPPING_MODAL_TYPE = 'product-edit-variant-shipping'; export const PRODUCT_EDIT_VARIANTS_MODAL_TYPE = 'product-edit-variants'; -export const PRODUCT_EDIT_CATEGORIES_MODAL_TYPE = 'product-edit-categories'; export const PRODUCT_EDIT_TAGS_MODAL_TYPE = 'product-edit-tags'; export const BULK_EDITOR_MODAL_TYPE = 'bulk-editor'; @@ -46,21 +55,21 @@ export interface IProductCreateModalPayload extends IModalStackPayload { export interface IProductEditTitleModalPayload extends IModalStackPayload { title: string; handle: string; - onSave?: (values: { title: string; handle: string }) => void; + onSave?: (values: { title: string; handle: string }) => boolean | void | Promise; } export interface IProductEditDescriptionModalPayload extends IModalStackPayload { description: OutputData | null; excerpt: OutputData | null; - product?: IProduct; - onSave?: (values: { description: RenderedContent; excerpt: RenderedContent }) => void; + product?: ApiProduct; + onSave?: (values: { description: RenderedContent; excerpt: RenderedContent }) => boolean | void | Promise; } export type AIGenerateTarget = 'description' | 'excerpt' | 'both'; export type AITone = 'professional' | 'casual' | 'luxury' | 'friendly'; export interface IProductAIWriterModalPayload extends IModalStackPayload { - product: IProduct; + product: ApiProduct; onApply?: (values: { description?: RenderedContent; excerpt?: RenderedContent; @@ -68,20 +77,7 @@ export interface IProductAIWriterModalPayload extends IModalStackPayload { } export interface IProductPriceHistoryModalPayload extends IModalStackPayload { - productId?: string; - variantId?: string; - currentPrice: number; - compareAtPrice?: number | null; - costPrice?: number | null; - priceHistory: ApiVariantPrice[]; - variants?: Array<{ - id: string; - title: string; - price: number; - compareAtPrice?: number | null; - priceHistory: ApiVariantPrice[]; - }>; - formatPrice?: (amount: number) => string; + productId: string; } export interface IVariantPricingOption { @@ -97,60 +93,50 @@ export interface IEditVariantPricingModalPayload extends IModalStackPayload { variants: Array<{ id: string; title: string; - price: number; + price: number | null; compareAtPrice?: number | null; costPrice?: number | null; options?: IVariantPricingOption[]; }>; - formatPrice?: (amount: number) => string; onSave?: (variants: Array<{ id: string; - price: number; + price: number | null; compareAtPrice: number | null; costPrice: number | null; }>) => void; } -export interface IEditVariantInventoryModalPayload extends IModalStackPayload { - productId?: string; - variants: Array<{ - id: string; - title: string; - sku?: string | null; - stock?: number; - weight?: number | null; - weightUnit?: string; - barcode?: string | null; - options?: IVariantPricingOption[]; - }>; - lowStockThreshold?: number; - onSave?: (variants: Array<{ - id: string; - sku: string | null; - stock: number; - weight: number | null; - weightUnit: string; - barcode: string | null; - }>) => void; -} - export interface IEditMediaModalPayload extends IModalStackPayload { productId?: string; - variantId?: string; + title?: string; + galleryTitle?: string; featured: ApiFile | null; gallery: ApiFile[]; + selectionMode?: boolean; + selectedFileIds?: string[]; + showUpload?: boolean; + allowDelete?: boolean; + allowSetFeatured?: boolean; + hasFeatured?: boolean; + accept?: string; + maxSize?: number; + maxFiles?: number; onSave?: (media: { featured: ApiFile | null; gallery: ApiFile[]; - }) => void; + }) => boolean | void | Promise; } export interface IEditOptionsModalPayload extends IModalStackPayload { - productId?: string; + productId: string; + options: ApiProductOption[]; + onSaved?: () => Promise | unknown; } export interface IEditAttributesModalPayload extends IModalStackPayload { - productId?: string; + productId: string; + features: ApiProductFeature[]; + onSaved?: () => Promise | unknown; } export interface IEditSeoModalPayload extends IModalStackPayload { @@ -172,7 +158,7 @@ export interface IEditSeoModalPayload extends IModalStackPayload { ogTitle: string; ogDescription: string; ogImage: ApiFile | null; - }) => void; + }) => boolean | void | Promise; } export interface IEditVariantShippingModalPayload extends IModalStackPayload { @@ -200,104 +186,66 @@ export interface IEditVariantShippingModalPayload extends IModalStackPayload { }>) => void; } -export type VariantTabKey = 'inventory' | 'pricing' | 'shipping' | 'media' | 'options'; - // Re-export for convenience export type { VariantColumnField }; +export interface EditVariantsSaveResult { + ok: boolean; + operationResults: ApiOperationResult[]; + userErrors: ApiGenericUserError[]; + submittedVariantOperations?: ApiVariantOperationInput[]; +} + +export interface EditVariantsSaveInput { + existingRows: VariantEditorSaveRow[]; + draftRows: VariantEditorSaveRow[]; + deletedRows: VariantEditorSaveRow[]; + additionalOperations?: ApiProductUpdateInput; +} + export interface IEditVariantsModalPayload extends IModalStackPayload { productId?: string; - initialTab?: VariantTabKey; - variants: Array<{ - id: string; - title: string; - imageUrl?: string | null; - media?: string[] | null; - // Inventory identification - sku?: string | null; - barcode?: string | null; - // Inventory quantities (same model as inventory table) - onHand?: number; - unavailable?: number; - reserved?: number; - // Pricing - price?: number; - compareAtPrice?: number | null; - costPrice?: number | null; - // Shipping - weight?: number | null; - weightUnit?: string; - length?: number | null; - width?: number | null; - height?: number | null; - dimensionUnit?: string; - // Options - options?: IVariantPricingOption[]; - }>; - formatPrice?: (amount: number) => string; + variants: ApiVariant[]; + productMediaFiles?: ApiFile[]; + productOptions: ApiProductOption[]; + defaultCurrency?: CurrencyCode | null; /** * When provided, only these columns will be shown. * If undefined, all columns are available with user settings. - * Example: ['price', 'compareAtPrice', 'costPrice'] for pricing-only modal. + * Example: ['price', 'compareAtPrice'] for pricing-only modal. */ availableColumns?: VariantColumnField[]; + /** + * When provided, controls which visible columns can create draft edits. + * If undefined, visible editable columns use their config editability. + */ + editableColumns?: VariantColumnField[]; /** * Whether to show the column settings button. Defaults to true. * Set to false when using restricted columns without user customization. */ showColumnSettings?: boolean; - onSave?: (variants: Array<{ - id: string; - sku: string | null; - barcode: string | null; - // Inventory quantities - onHand: number; - unavailable: number; - reserved: number; - available: number; - price: number; - compareAtPrice: number | null; - costPrice: number | null; - weight: number | null; - weightUnit: string; - length: number | null; - width: number | null; - height: number | null; - dimensionUnit: string; - }>) => void; -} - -export interface IEditCategoriesModalPayload extends IModalStackPayload { - productId?: string; - primaryCategoryId?: string | null; - categoryIds?: string[]; - availableCategories?: Array<{ - id: string; - title: string; - slug: string; - }>; - categoryHierarchy?: Record; - onSave?: (data: { - primaryCategoryId: string | null; - categoryIds: string[]; - }) => void; -} - -export interface ITag { - id: string; - title: string; - slug: string; - color?: string; + /** + * Whether this editor session can create draft variant rows. Defaults to true. + */ + allowDraftRows?: boolean; + /** + * Whether this editor session can delete variant rows. Defaults to allowDraftRows. + */ + allowDeleteRows?: boolean; + onSave?: ( + input: EditVariantsSaveInput, + ) => EditVariantsSaveResult | Promise; } export interface IEditTagsModalPayload extends IModalStackPayload { productId?: string; selectedTagIds?: string[]; - availableTags?: ITag[]; + availableTags?: ApiTag[]; onSave?: (data: { tagIds: string[]; }) => void; - onCreateTag?: (title: string) => Promise; + onCreateTag?: (name: string) => Promise; } @@ -319,14 +267,12 @@ declare module '@/layouts/modals' { [PRODUCT_AI_WRITER_MODAL_TYPE]: IProductAIWriterModalPayload; [PRODUCT_PRICE_HISTORY_MODAL_TYPE]: IProductPriceHistoryModalPayload; [PRODUCT_EDIT_VARIANT_PRICING_MODAL_TYPE]: IEditVariantPricingModalPayload; - [PRODUCT_EDIT_VARIANT_INVENTORY_MODAL_TYPE]: IEditVariantInventoryModalPayload; [PRODUCT_EDIT_MEDIA_MODAL_TYPE]: IEditMediaModalPayload; [PRODUCT_EDIT_OPTIONS_MODAL_TYPE]: IEditOptionsModalPayload; [PRODUCT_EDIT_ATTRIBUTES_MODAL_TYPE]: IEditAttributesModalPayload; [PRODUCT_EDIT_SEO_MODAL_TYPE]: IEditSeoModalPayload; [PRODUCT_EDIT_VARIANT_SHIPPING_MODAL_TYPE]: IEditVariantShippingModalPayload; [PRODUCT_EDIT_VARIANTS_MODAL_TYPE]: IEditVariantsModalPayload; - [PRODUCT_EDIT_CATEGORIES_MODAL_TYPE]: IEditCategoriesModalPayload; [PRODUCT_EDIT_TAGS_MODAL_TYPE]: IEditTagsModalPayload; [BULK_EDITOR_MODAL_TYPE]: IBulkEditorModalPayload; } @@ -364,7 +310,7 @@ export const useProductCreateModal = createModalStackHook(PRODUCT_CREATE_MODAL_T * @example * ```tsx * const { push } = useProductEditTitleModal(); - * push({ title: 'Product Name', handle: 'product-name', onSave: (values) => console.log(values) }); + * push({ title: 'Product Name', handle: 'product-name', onSave: saveTitle }); * ``` */ export const useProductEditTitleModal = createModalStackHook(PRODUCT_EDIT_TITLE_MODAL_TYPE); @@ -375,7 +321,7 @@ export const useProductEditTitleModal = createModalStackHook(PRODUCT_EDIT_TITLE_ * @example * ```tsx * const { push } = useProductEditDescriptionModal(); - * push({ description: 'Product desc', excerpt: 'Short excerpt', onSave: (values) => console.log(values) }); + * push({ description: null, excerpt: null, onSave: saveContent }); * ``` */ export const useProductEditDescriptionModal = createModalStackHook(PRODUCT_EDIT_DESCRIPTION_MODAL_TYPE); @@ -386,7 +332,7 @@ export const useProductEditDescriptionModal = createModalStackHook(PRODUCT_EDIT_ * @example * ```tsx * const { push } = useProductAIWriterModal(); - * push({ product, onApply: (values) => console.log(values) }); + * push({ product, onApply: applyGeneratedContent }); * ``` */ export const useProductAIWriterModal = createModalStackHook(PRODUCT_AI_WRITER_MODAL_TYPE); @@ -408,29 +354,18 @@ export const useProductPriceHistoryModal = createModalStackHook(PRODUCT_PRICE_HI * @example * ```tsx * const { push } = useEditVariantPricingModal(); - * push({ variants: [...], onSave: (variants) => console.log(variants) }); + * push({ variants: [...], onSave: saveVariantPricing }); * ``` */ export const useEditVariantPricingModal = createModalStackHook(PRODUCT_EDIT_VARIANT_PRICING_MODAL_TYPE); -/** - * Hook to open edit variant inventory modal - * - * @example - * ```tsx - * const { push } = useEditVariantInventoryModal(); - * push({ variants: [...], onSave: (variants) => console.log(variants) }); - * ``` - */ -export const useEditVariantInventoryModal = createModalStackHook(PRODUCT_EDIT_VARIANT_INVENTORY_MODAL_TYPE); - /** * Hook to open edit media modal * * @example * ```tsx * const { push } = useEditMediaModal(); - * push({ featured: null, gallery: [...], onSave: (media) => console.log(media) }); + * push({ featured: null, gallery: [...], onSave: saveMedia }); * ``` */ export const useEditMediaModal = createModalStackHook(PRODUCT_EDIT_MEDIA_MODAL_TYPE); @@ -469,7 +404,7 @@ export const useEditAttributesModal = createModalStackHook(PRODUCT_EDIT_ATTRIBUT * seoTitle: 'Custom SEO Title', * seoDescription: 'Custom meta description', * slug: 'product-name', - * onSave: (values) => console.log(values) + * onSave: saveSeo * }); * ``` */ @@ -481,7 +416,7 @@ export const useEditSeoModal = createModalStackHook(PRODUCT_EDIT_SEO_MODAL_TYPE) * @example * ```tsx * const { push } = useEditVariantShippingModal(); - * push({ variants: [...], onSave: (variants) => console.log(variants) }); + * push({ variants: [...], onSave: saveVariantShipping }); * ``` */ export const useEditVariantShippingModal = createModalStackHook(PRODUCT_EDIT_VARIANT_SHIPPING_MODAL_TYPE); @@ -492,27 +427,11 @@ export const useEditVariantShippingModal = createModalStackHook(PRODUCT_EDIT_VAR * @example * ```tsx * const { push } = useEditVariantsModal(); - * push({ variants: [...], initialTab: 'pricing', onSave: (variants) => console.log(variants) }); + * push({ variants: [...], onSave: saveVariants }); * ``` */ export const useEditVariantsModal = createModalStackHook(PRODUCT_EDIT_VARIANTS_MODAL_TYPE); -/** - * Hook to open edit categories modal - * - * @example - * ```tsx - * const { push } = useEditCategoriesModal(); - * push({ - * productId: 'prod-123', - * primaryCategoryId: 'cat-1', - * categoryIds: ['cat-1', 'cat-2'], - * onSave: (data) => console.log(data) - * }); - * ``` - */ -export const useEditCategoriesModal = createModalStackHook(PRODUCT_EDIT_CATEGORIES_MODAL_TYPE); - /** * Hook to open edit tags modal * @@ -522,7 +441,7 @@ export const useEditCategoriesModal = createModalStackHook(PRODUCT_EDIT_CATEGORI * push({ * productId: 'prod-123', * selectedTagIds: ['tag-1', 'tag-2'], - * onSave: (data) => console.log(data) + * onSave: saveTags * }); * ``` */ diff --git a/admin/src/domains/inventory/products/modals/ai-writer-modal/ai-writer-modal.tsx b/admin/src/domains/inventory/products/modals/ai-writer-modal/ai-writer-modal.tsx index 8e57ab2e3..ba512a6fd 100644 --- a/admin/src/domains/inventory/products/modals/ai-writer-modal/ai-writer-modal.tsx +++ b/admin/src/domains/inventory/products/modals/ai-writer-modal/ai-writer-modal.tsx @@ -12,12 +12,7 @@ import { Alert, Divider, } from "antd"; -import { - ThunderboltOutlined, - ReloadOutlined, - TagOutlined, - FolderOutlined, -} from "@ant-design/icons"; +import { LuZap as ThunderboltOutlined, LuRefreshCw as ReloadOutlined, LuTag as TagOutlined, LuFolder as FolderOutlined } from "react-icons/lu"; import { useModalStackContext, ModalLayout, @@ -32,6 +27,10 @@ import type { import { useStyles } from "./ai-writer-modal.styles"; import type { IAIWriterForm, IGeneratedContent } from "./types"; import { mockGenerateContent } from "@/mocks/products/ai-writer"; +import { + getProductPrimaryCategoryName, + getProductPrimaryPriceAmount, +} from "../../utils/api-product-display"; export const AIWriterModal = () => { const { styles } = useStyles(); @@ -66,14 +65,15 @@ export const AIWriterModal = () => { // Build product context for AI const productContext = { title: typedPayload.product.title, - category: - typedPayload.product.primaryCategory?.title || - typedPayload.product.categories[0]?.title || - null, - attributes: typedPayload.product.attributes - .flatMap((g) => g.features.map((f) => `${g.title}: ${f.title}`)) + category: getProductPrimaryCategoryName(typedPayload.product), + attributes: typedPayload.product.features + .flatMap((feature) => + feature.values.length > 0 + ? feature.values.map((value) => `${feature.name}: ${value.name}`) + : [feature.name], + ) .slice(0, 5), - price: typedPayload.product.price, + price: getProductPrimaryPriceAmount(typedPayload.product) ?? 0, }; const handleGenerate = async (values: IAIWriterForm) => { diff --git a/admin/src/domains/inventory/products/modals/bulk-editor-modal/bulk-editor-modal.tsx b/admin/src/domains/inventory/products/modals/bulk-editor-modal/bulk-editor-modal.tsx index dd045f5e9..653611975 100644 --- a/admin/src/domains/inventory/products/modals/bulk-editor-modal/bulk-editor-modal.tsx +++ b/admin/src/domains/inventory/products/modals/bulk-editor-modal/bulk-editor-modal.tsx @@ -90,7 +90,7 @@ export const BulkEditorModal: React.FC = () => { const headerExtra = (
    - +
    ); diff --git a/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/bulk-editor-grid.tsx b/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/bulk-editor-grid.tsx index ca9fd62ea..e2a309983 100644 --- a/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/bulk-editor-grid.tsx +++ b/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/bulk-editor-grid.tsx @@ -44,11 +44,6 @@ const useStyles = createStyles(({ token }) => ({ const SELECTABLE_COLUMNS = [ "price", "compareAtPrice", - "costPrice", - "onHand", - "unavailable", - "sku", - "barcode", "weight", "length", "width", diff --git a/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/cell-renderers/index.tsx b/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/cell-renderers/index.tsx index adb5df958..755c3d975 100644 --- a/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/cell-renderers/index.tsx +++ b/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/cell-renderers/index.tsx @@ -15,6 +15,10 @@ import { CalculatedAvailableCell, } from "@/shared/components/inventory-cells"; +function isEmptyCellValue(value: unknown): boolean { + return value === null || value === undefined || value === ""; +} + // Title cell with hierarchy export const TitleCellRenderer: React.FC< CustomCellRendererProps @@ -125,7 +129,7 @@ export const TextCellRenderer: React.FC< return ( - {value ?? ""} + {isEmptyCellValue(value) ? : {String(value)}} ); }; @@ -150,7 +154,7 @@ export const PriceCellRenderer: React.FC< return ( - {formatPrice(value as number | null)} + {isEmptyCellValue(value) ? : formatPrice(value as number)} ); }; @@ -180,8 +184,10 @@ export const NumberCellRenderer: React.FC< {edit ? ( + ) : isEmptyCellValue(value) ? ( + ) : ( - (value ?? "") + value )} ); diff --git a/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/column-settings-popover.tsx b/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/column-settings-popover.tsx index f91ff6a97..73d2179a2 100644 --- a/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/column-settings-popover.tsx +++ b/admin/src/domains/inventory/products/modals/bulk-editor-modal/components/column-settings-popover.tsx @@ -1,12 +1,11 @@ import React from "react"; import { createStyles } from "antd-style"; import { Popover, Checkbox, Divider, Typography, Button } from "antd"; -import { SettingOutlined } from "@ant-design/icons"; +import { LuSettings as SettingOutlined } from "react-icons/lu"; import { useBulkEditorStore } from "../hooks/use-bulk-editor-store"; import { PRODUCT_COLUMNS, PRICING_COLUMNS, - INVENTORY_COLUMNS, ATTRIBUTES_COLUMNS, } from "../types"; @@ -46,7 +45,6 @@ export const ColumnSettingsPopover: React.FC = () => { const sections = [ { title: "General", columns: PRODUCT_COLUMNS }, { title: "Pricing", columns: PRICING_COLUMNS }, - { title: "Inventory", columns: INVENTORY_COLUMNS }, { title: "Attributes", columns: ATTRIBUTES_COLUMNS }, ]; diff --git a/admin/src/domains/inventory/products/modals/bulk-editor-modal/hooks/use-bulk-editor-columns.ts b/admin/src/domains/inventory/products/modals/bulk-editor-modal/hooks/use-bulk-editor-columns.ts index ef1c8279c..c198d91cd 100644 --- a/admin/src/domains/inventory/products/modals/bulk-editor-modal/hooks/use-bulk-editor-columns.ts +++ b/admin/src/domains/inventory/products/modals/bulk-editor-modal/hooks/use-bulk-editor-columns.ts @@ -11,7 +11,6 @@ import { ALL_COLUMNS, PRODUCT_COLUMNS, PRICING_COLUMNS, - INVENTORY_COLUMNS, ATTRIBUTES_COLUMNS, VARIANT_FIELDS, } from "../types"; @@ -91,7 +90,7 @@ function createValueSetter(field: keyof IBulkEditorRow) { } // Price fields -const PRICE_FIELDS = new Set(["price", "compareAtPrice", "costPrice"]); +const PRICE_FIELDS = new Set(["price", "compareAtPrice"]); // Get cell renderer based on column type and field function getCellRenderer(column: (typeof ALL_COLUMNS)[0]) { @@ -153,7 +152,6 @@ function getCellEditorParams(column: (typeof ALL_COLUMNS)[0]) { return { values: ["mm", "cm", "m", "in"] }; case "price": case "compareAtPrice": - case "costPrice": return { min: 0, precision: 2 }; case "onHand": case "unavailable": @@ -207,11 +205,7 @@ export function useBulkEditorColumns(): ColDef[] { } // Variant columns (including all groups) - const variantColumnGroups = [ - PRICING_COLUMNS, - INVENTORY_COLUMNS, - ATTRIBUTES_COLUMNS, - ]; + const variantColumnGroups = [PRICING_COLUMNS, ATTRIBUTES_COLUMNS]; for (const group of variantColumnGroups) { for (const col of group) { diff --git a/admin/src/domains/inventory/products/modals/bulk-editor-modal/index.ts b/admin/src/domains/inventory/products/modals/bulk-editor-modal/index.ts index 98b530654..f9f98ccdd 100644 --- a/admin/src/domains/inventory/products/modals/bulk-editor-modal/index.ts +++ b/admin/src/domains/inventory/products/modals/bulk-editor-modal/index.ts @@ -2,4 +2,3 @@ export { BulkEditorModal } from "./bulk-editor-modal"; export { useBulkEditorStore } from "./hooks/use-bulk-editor-store"; export { useBulkEditorData } from "./hooks/use-bulk-editor-data"; export { useBulkEditorColumns } from "./hooks/use-bulk-editor-columns"; -export * from "./types"; diff --git a/admin/src/domains/inventory/products/modals/bulk-editor-modal/types/index.ts b/admin/src/domains/inventory/products/modals/bulk-editor-modal/types/index.ts index 88b30829e..8e6e39701 100644 --- a/admin/src/domains/inventory/products/modals/bulk-editor-modal/types/index.ts +++ b/admin/src/domains/inventory/products/modals/bulk-editor-modal/types/index.ts @@ -175,73 +175,9 @@ export const PRICING_COLUMNS: IBulkEditorColumn[] = [ width: 130, type: "number", }, - { - field: "costPrice", - headerName: "Cost", - category: "pricing", - defaultVisible: false, - editable: true, - width: 110, - type: "number", - }, ]; -export const INVENTORY_COLUMNS: IBulkEditorColumn[] = [ - { - field: "sku", - headerName: "SKU", - category: "inventory", - defaultVisible: true, - editable: true, - width: 120, - type: "text", - }, - { - field: "barcode", - headerName: "Barcode", - category: "inventory", - defaultVisible: false, - editable: true, - width: 140, - type: "text", - }, - { - field: "onHand", - headerName: "On hand", - category: "inventory", - defaultVisible: true, - editable: true, - width: 100, - type: "number", - }, - { - field: "unavailable", - headerName: "Unavailable", - category: "inventory", - defaultVisible: true, - editable: true, - width: 110, - type: "number", - }, - { - field: "reserved", - headerName: "Reserved", - category: "inventory", - defaultVisible: true, - editable: false, // managed by order system - width: 100, - type: "number", - }, - { - field: "available", - headerName: "Available", - category: "inventory", - defaultVisible: true, - editable: false, // calculated - width: 100, - type: "number", - }, -]; +export const INVENTORY_COLUMNS: IBulkEditorColumn[] = []; export const ATTRIBUTES_COLUMNS: IBulkEditorColumn[] = [ { @@ -285,7 +221,6 @@ export const ATTRIBUTES_COLUMNS: IBulkEditorColumn[] = [ export const ALL_COLUMNS = [ ...PRODUCT_COLUMNS, ...PRICING_COLUMNS, - ...INVENTORY_COLUMNS, ...ATTRIBUTES_COLUMNS, ]; @@ -321,8 +256,7 @@ export function shouldShowDash( } // Format price for display -export function formatPrice(value: number | null): string { - if (value === null) return ""; +export function formatPrice(value: number): string { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", diff --git a/admin/src/domains/inventory/products/modals/create-product-modal/create-product-modal.tsx b/admin/src/domains/inventory/products/modals/create-product-modal/create-product-modal.tsx index 29b5fcf70..9e0a610fe 100644 --- a/admin/src/domains/inventory/products/modals/create-product-modal/create-product-modal.tsx +++ b/admin/src/domains/inventory/products/modals/create-product-modal/create-product-modal.tsx @@ -16,6 +16,7 @@ import { createProductSchema } from "./schema"; import { GeneralSection } from "./general-section"; import { MediaSection } from "./media-section"; import { VariantsSection } from "./variants-section"; +import { mapProductUserErrorsToFormErrors } from "../../mappers"; const useStyles = createStyles(() => ({ container: { @@ -61,21 +62,10 @@ export const CreateProductModal = () => { }); if (userErrors.length > 0) { - // Map API errors to form errors - userErrors.forEach((error) => { - if (error.field && error.field.length > 0) { - const fieldName = error.field[error.field.length - 1]; - if ( - fieldName === "title" || - fieldName === "handle" || - fieldName === "description" - ) { - setError(fieldName, { message: error.message }); - } - } + mapProductUserErrorsToFormErrors(userErrors).forEach((error) => { + setError(error.field, { message: error.message }); }); - // Show first error as notification message.error(userErrors[0].message); return; } diff --git a/admin/src/domains/inventory/products/modals/create-product-modal/general-section.tsx b/admin/src/domains/inventory/products/modals/create-product-modal/general-section.tsx index dec9b914c..e5ec5248a 100644 --- a/admin/src/domains/inventory/products/modals/create-product-modal/general-section.tsx +++ b/admin/src/domains/inventory/products/modals/create-product-modal/general-section.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { useFormContext, Controller } from "react-hook-form"; import { Input, Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { LuCircleHelp as InfoCircleOutlined } from "react-icons/lu"; import { createStyles } from "antd-style"; import { slugify } from "transliteration/dist/node/src/node/index.js"; import { Paper, PaperHeader } from "@/ui-kit/paper"; @@ -70,6 +70,7 @@ export const GeneralSection = () => { <> @@ -95,6 +96,7 @@ export const GeneralSection = () => { <> { @@ -118,12 +120,14 @@ export const GeneralSection = () => { control={control} render={({ field, fieldState: { error } }) => ( <> - +
    + +
    {error &&
    {error.message}
    } )} diff --git a/admin/src/domains/inventory/products/modals/create-product-modal/index.ts b/admin/src/domains/inventory/products/modals/create-product-modal/index.ts index 38d224cbc..c65385596 100644 --- a/admin/src/domains/inventory/products/modals/create-product-modal/index.ts +++ b/admin/src/domains/inventory/products/modals/create-product-modal/index.ts @@ -1,4 +1,2 @@ export { CreateProductModal } from './create-product-modal'; -export type { ICreateProductFormValues } from './types'; -export type { IOptionInput, IGeneratedVariant } from './utils/generate-variants'; export { generateVariants, countPotentialVariants, cartesianProduct } from './utils/generate-variants'; diff --git a/admin/src/domains/inventory/products/modals/create-product-modal/utils/prepare-product-payload.ts b/admin/src/domains/inventory/products/modals/create-product-modal/utils/prepare-product-payload.ts index a0af38260..598e3e04a 100644 --- a/admin/src/domains/inventory/products/modals/create-product-modal/utils/prepare-product-payload.ts +++ b/admin/src/domains/inventory/products/modals/create-product-modal/utils/prepare-product-payload.ts @@ -1,165 +1,12 @@ -import type { OutputData } from '@editorjs/editorjs'; -import { slugify } from 'transliteration/dist/node/src/node/index.js'; -import type { ApiProductCreateInput, ApiFile } from '@/graphql/types'; -import { renderContent } from '@/ui-kit/editor'; - -// ============================================ -// Types -// ============================================ - -export interface IOptionValueInput { - value: string; - slug: string; -} - -export interface IOptionInput { - id: string; - name: string; - values: IOptionValueInput[]; -} - -export interface IGeneratedVariant { - id: string; - title: string; - options: Array<{ name: string; value: string; slug: string }>; - enabled: boolean; -} - -export interface CreateProductInput { - title: string; - handle: string; - description: OutputData | null; - media: ApiFile[]; - hasVariants: boolean; - options: IOptionInput[]; - variants: IGeneratedVariant[]; -} - -// ============================================ -// Payload Preparation -// ============================================ - -/** - * Prepares description input for the API. - * Returns undefined if description is empty. - */ -export function prepareDescription(description: OutputData | null) { - if (!description || !description.blocks?.length) { - return undefined; - } - - const rendered = renderContent(description); - - return { - text: rendered.plain, - html: rendered.html, - json: rendered.json as unknown as Record, - }; -} - -/** - * Extracts media file IDs from uploaded files. - * Returns undefined if no media files are provided. - */ -export function prepareMediaFileIds(media: ApiFile[]): string[] | undefined { - if (!media || media.length === 0) { - return undefined; - } - - return media.map((m) => m.id); -} - -/** - * Filters and transforms options for the API. - * Returns undefined if variants are disabled or no valid options exist. - */ -export function prepareOptions( - hasVariants: boolean, - options: IOptionInput[] -): ApiProductCreateInput['options'] { - if (!hasVariants || !options || options.length === 0) { - return undefined; - } - - const validOptions = options.filter( - (opt) => opt.name.trim() && opt.values.length > 0 - ); - - if (validOptions.length === 0) { - return undefined; - } - - return validOptions.map((opt) => ({ - name: opt.name, - slug: slugify(opt.name), - values: opt.values.map((v) => ({ - name: v.value, - slug: v.slug, - })), - })); -} - -/** - * Filters enabled variants and transforms them for the API. - * Returns undefined if variants are disabled or no enabled variants exist. - */ -export function prepareVariants( - hasVariants: boolean, - variants: IGeneratedVariant[] -): ApiProductCreateInput['variants'] { - if (!hasVariants || !variants || variants.length === 0) { - return undefined; - } - - const enabledVariants = variants.filter((v) => v.enabled); - - if (enabledVariants.length === 0) { - return undefined; - } - - return enabledVariants.map((v) => ({ - handle: v.id, // id is built from option value slugs (e.g., "red-s") - })); -} - -/** - * Prepares the complete product creation payload for the API. - * - * This function transforms form data into the API-expected format: - * - Formats description into text/html/json structure - * - Extracts media file IDs from uploaded files - * - Filters and transforms options (only when hasVariants is true) - * - Filters enabled variants and extracts their handles - * - * @param input - Form data from the create product form - * @returns API-ready payload for productCreate mutation - * - * @example - * ```ts - * const payload = prepareProductPayload({ - * title: 'T-Shirt', - * handle: 't-shirt', - * description: 'A comfortable cotton t-shirt', - * media: [{ id: 'file-1', url: '...' }], - * hasVariants: true, - * options: [ - * { id: '1', name: 'Color', values: [{ value: 'Red', slug: 'red' }] } - * ], - * variants: [ - * { id: 'red', title: 'Red', options: [...], enabled: true } - * ], - * }); - * ``` - */ -export function prepareProductPayload( - input: CreateProductInput -): ApiProductCreateInput { - return { - title: input.title, - handle: input.handle, - description: prepareDescription(input.description), - mediaFileIds: prepareMediaFileIds(input.media), - options: prepareOptions(input.hasVariants, input.options), - variants: prepareVariants(input.hasVariants, input.variants), - }; -} +export { + prepareDescription, + prepareMediaFileIds, + prepareOptions, + prepareProductPayload, + prepareVariants, +} from "../../../mappers/product-create.mapper"; +export type { + CreateProductInput, + IGeneratedVariant, + IOptionInput, +} from "../../../mappers/product-create.mapper"; diff --git a/admin/src/domains/inventory/products/modals/create-product-modal/variants-section.tsx b/admin/src/domains/inventory/products/modals/create-product-modal/variants-section.tsx index 82f12473b..7e327c2ba 100644 --- a/admin/src/domains/inventory/products/modals/create-product-modal/variants-section.tsx +++ b/admin/src/domains/inventory/products/modals/create-product-modal/variants-section.tsx @@ -3,7 +3,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef } from "react"; import { useFormContext } from "react-hook-form"; import { Input, Select, Button, Typography, Flex, Switch, Alert } from "antd"; -import { PlusOutlined, CloseOutlined } from "@ant-design/icons"; +import { LuPlus as PlusOutlined, LuX as CloseOutlined } from "react-icons/lu"; import { AgGridReact } from "ag-grid-react"; import type { ColDef, @@ -153,12 +153,16 @@ const OptionCard = ({ }; return ( -
    +
    Title
    onUpdateName(option.id, e.target.value)} @@ -168,6 +172,7 @@ const OptionCard = ({
    Values
    } - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - allowClear - /> -
    - -
    - {treeData.length > 0 ? ( - - ) : ( - - )} -
    - -
    - - ); -}; diff --git a/admin/src/domains/inventory/products/modals/edit-categories-modal/index.ts b/admin/src/domains/inventory/products/modals/edit-categories-modal/index.ts deleted file mode 100644 index 4e8a576a3..000000000 --- a/admin/src/domains/inventory/products/modals/edit-categories-modal/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { EditCategoriesModal } from "./edit-categories-modal"; -export type { IEditCategoriesModalProps } from "./edit-categories-modal"; diff --git a/admin/src/domains/inventory/products/modals/edit-categories-modal/types.ts b/admin/src/domains/inventory/products/modals/edit-categories-modal/types.ts deleted file mode 100644 index 6903607a6..000000000 --- a/admin/src/domains/inventory/products/modals/edit-categories-modal/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { TreeDataNode } from "antd"; -import type { ICategory } from "@/mocks/products/types"; - -export interface ICategoryTreeNode extends TreeDataNode { - category: ICategory; - children?: ICategoryTreeNode[]; -} - -export interface IEditCategoriesModalProps { - productId?: string; - primaryCategoryId?: string | null; - categoryIds?: string[]; - availableCategories?: ICategory[]; - categoryHierarchy?: Record; - onSave?: (data: { - primaryCategoryId: string | null; - categoryIds: string[]; - }) => void; -} diff --git a/admin/src/domains/inventory/products/modals/edit-categories-modal/utils.ts b/admin/src/domains/inventory/products/modals/edit-categories-modal/utils.ts deleted file mode 100644 index 28b08c306..000000000 --- a/admin/src/domains/inventory/products/modals/edit-categories-modal/utils.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ICategory } from "@/mocks/products/types"; -import type { ICategoryTreeNode } from "./types"; - -export const buildCategoryTree = ( - categories: ICategory[], - hierarchy: Record, - searchTerm: string -): ICategoryTreeNode[] => { - const childrenMap = new Map(); - - // Group categories by parent - categories.forEach((cat) => { - const parentId = hierarchy[cat.id] ?? null; - if (!childrenMap.has(parentId)) { - childrenMap.set(parentId, []); - } - childrenMap.get(parentId)!.push(cat); - }); - - // Filter by search term - const matchesSearch = (cat: ICategory): boolean => { - if (!searchTerm) return true; - const term = searchTerm.toLowerCase(); - return ( - cat.title.toLowerCase().includes(term) || - cat.slug.toLowerCase().includes(term) - ); - }; - - // Build tree recursively - const buildNode = (category: ICategory): ICategoryTreeNode | null => { - const children = childrenMap.get(category.id) || []; - const childNodes = children - .map((child) => buildNode(child)) - .filter((node): node is ICategoryTreeNode => node !== null); - - // Include if matches search or has matching descendants - if (!matchesSearch(category) && childNodes.length === 0) { - return null; - } - - return { - key: category.id, - title: category.title, - category, - children: childNodes.length > 0 ? childNodes : undefined, - }; - }; - - // Build root nodes - const rootCategories = childrenMap.get(null) || []; - return rootCategories - .map((cat) => buildNode(cat)) - .filter((node): node is ICategoryTreeNode => node !== null); -}; - -export const getAllKeys = (nodes: ICategoryTreeNode[]): string[] => { - const keys: string[] = []; - const collect = (items: ICategoryTreeNode[]) => { - items.forEach((node) => { - keys.push(node.key as string); - if (node.children) { - collect(node.children); - } - }); - }; - collect(nodes); - return keys; -}; diff --git a/admin/src/domains/inventory/products/modals/edit-description-modal/edit-description-modal.tsx b/admin/src/domains/inventory/products/modals/edit-description-modal/edit-description-modal.tsx index 3c1f71f5f..693e98fa1 100644 --- a/admin/src/domains/inventory/products/modals/edit-description-modal/edit-description-modal.tsx +++ b/admin/src/domains/inventory/products/modals/edit-description-modal/edit-description-modal.tsx @@ -1,134 +1,60 @@ "use client"; -import { useEffect } from "react"; -import { useForm, Controller } from "react-hook-form"; -import { Tabs } from "antd"; -import { - useModalStackContext, - ModalLayout, - ModalHeader, -} from "@/layouts/modals"; -import { Editor, renderContent, type RenderedContent } from "@/ui-kit/editor"; +import { useModalStackContext } from "@/layouts/modals"; +import { renderContent } from "@/ui-kit/editor"; +import type { RenderedContent } from "@/ui-kit/editor/renderers"; import { AIButton } from "@/ui-kit/ai-button"; -import { Paper } from "@/ui-kit/paper"; +import { EntityContentModal } from "@/domains/inventory/components/entity-edit-forms"; import type { IProductEditDescriptionModalPayload } from "../../modals"; import { useProductAIWriterModal } from "../../modals"; -import type { IEditDescriptionForm } from "./types"; export const EditDescriptionModal = () => { const { payload, pop } = useModalStackContext(); const typedPayload = payload as IProductEditDescriptionModalPayload; const { push: openAIWriterModal } = useProductAIWriterModal(); - const { control, handleSubmit, setValue } = useForm({ - defaultValues: { - description: typedPayload.description || null, - excerpt: typedPayload.excerpt || null, - }, - }); - - const handleWriteWithAI = () => { - if (!typedPayload.product) return; - openAIWriterModal({ - product: typedPayload.product, - onApply: (values: { - description?: RenderedContent; - excerpt?: RenderedContent; - }) => { - if (values.description?.json) { - setValue("description", values.description.json); - } - if (values.excerpt?.json) { - setValue("excerpt", values.excerpt.json); - } - }, - }); - }; - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - pop(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [pop]); - - const onSubmit = (values: IEditDescriptionForm) => { - typedPayload.onSave?.({ - description: renderContent(values.description), - excerpt: renderContent(values.excerpt), - }); - pop(); - }; - return ( - + title="Edit Content" + initialValues={{ + description: typedPayload.description || null, + excerpt: typedPayload.excerpt || null, + }} + descriptionPlaceholder="Start writing product description..." + excerptPlaceholder="Write a short product excerpt..." + descriptionTestId="edit-description-description-editor" + excerptTestId="edit-description-excerpt-editor" + onClose={pop} + onSubmit={(values) => + typedPayload.onSave?.({ + description: renderContent(values.description), + excerpt: renderContent(values.excerpt), + }) } - > - -
    - - ) : null - } - items={[ - { - key: "description", - label: "Description", - children: ( - ( - - )} - /> - ), - }, - { - key: "excerpt", - label: "Excerpt", - children: ( - ( - - )} - /> - ), - }, - ]} + renderExtraActions={({ setValue }) => + typedPayload.product ? ( + { + if (!typedPayload.product) return; + openAIWriterModal({ + product: typedPayload.product, + onApply: (values: { + description?: RenderedContent; + excerpt?: RenderedContent; + }) => { + if (values.description?.json) { + setValue("description", values.description.json); + } + if (values.excerpt?.json) { + setValue("excerpt", values.excerpt.json); + } + }, + }); + }} /> - -
    -
    + ) : null + } + /> ); }; diff --git a/admin/src/domains/inventory/products/modals/edit-description-modal/types.ts b/admin/src/domains/inventory/products/modals/edit-description-modal/types.ts deleted file mode 100644 index 8ef4092ef..000000000 --- a/admin/src/domains/inventory/products/modals/edit-description-modal/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { OutputData } from "@editorjs/editorjs"; - -export interface IEditDescriptionForm { - description: OutputData | null; - excerpt: OutputData | null; -} diff --git a/admin/src/domains/inventory/products/modals/edit-media-modal/edit-media-modal.tsx b/admin/src/domains/inventory/products/modals/edit-media-modal/edit-media-modal.tsx index 3c06e5e9b..4b29a1ab8 100644 --- a/admin/src/domains/inventory/products/modals/edit-media-modal/edit-media-modal.tsx +++ b/admin/src/domains/inventory/products/modals/edit-media-modal/edit-media-modal.tsx @@ -13,19 +13,28 @@ import { useStyles } from "./edit-media-modal.styles"; export const EditMediaModal = () => { const { styles } = useStyles(); - const { payload, pop, setDirty } = useModalStackContext(); + const { payload, pop, forcePop, setDirty } = useModalStackContext(); const typedPayload = payload as IEditMediaModalPayload; + const [submitting, setSubmitting] = useState(false); + const selectionMode = typedPayload.selectionMode ?? false; + const hasFeatured = typedPayload.hasFeatured ?? !selectionMode; + const showUpload = typedPayload.showUpload ?? !selectionMode; + const allowDelete = typedPayload.allowDelete ?? !selectionMode; + const allowSetFeatured = typedPayload.allowSetFeatured ?? !selectionMode; const [gallery, setGallery] = useState(() => { const items = [...typedPayload.gallery]; if ( typedPayload.featured && - !items.find((i) => i.id === typedPayload.featured?.id) + !items.find((item) => item.id === typedPayload.featured?.id) ) { items.unshift(typedPayload.featured); } return items; }); + const [selectedFileIds, setSelectedFileIds] = useState( + () => typedPayload.selectedFileIds ?? [], + ); const markDirty = useCallback(() => { setDirty(true); @@ -49,11 +58,34 @@ export const EditMediaModal = () => { [markDirty] ); - const handleSave = useCallback(() => { - const newFeatured = gallery[0] || null; - typedPayload.onSave?.({ featured: newFeatured, gallery }); - pop(); - }, [typedPayload, gallery, pop]); + const handleSelectedFileIdsChange = useCallback( + (ids: string[]) => { + setSelectedFileIds(ids); + markDirty(); + }, + [markDirty], + ); + + const handleSave = useCallback(async () => { + setSubmitting(true); + + try { + const selectedFileIdSet = new Set(selectedFileIds); + const resultGallery = selectionMode + ? gallery.filter((file) => selectedFileIdSet.has(file.id)) + : gallery; + const result = await typedPayload.onSave?.({ + featured: resultGallery[0] ?? null, + gallery: resultGallery, + }); + + if (result !== false) { + forcePop(); + } + } finally { + setSubmitting(false); + } + }, [typedPayload, gallery, forcePop, selectedFileIds, selectionMode]); return ( { header={ } @@ -74,10 +107,18 @@ export const EditMediaModal = () => { diff --git a/admin/src/domains/inventory/products/modals/edit-options-modal/components/sortable-option-group.tsx b/admin/src/domains/inventory/products/modals/edit-options-modal/components/sortable-option-group.tsx index ecd424d02..b44e5ab51 100644 --- a/admin/src/domains/inventory/products/modals/edit-options-modal/components/sortable-option-group.tsx +++ b/admin/src/domains/inventory/products/modals/edit-options-modal/components/sortable-option-group.tsx @@ -1,5 +1,5 @@ import { Button, Dropdown, Flex, Input } from "antd"; -import { DeleteOutlined, HolderOutlined, PlusOutlined } from "@ant-design/icons"; +import { LuTrash2 as DeleteOutlined, LuGripVertical as HolderOutlined, LuPlus as PlusOutlined } from "react-icons/lu"; import { DndContext, KeyboardSensor, @@ -19,27 +19,27 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { - OptionDisplayType, - type ApiProductOption, - type ApiProductOptionValue, - type ApiProductOptionSwatchInput, -} from "@/graphql/types"; +import { OptionDisplayType } from "@/graphql/types"; import { useStyles } from "../edit-options-modal.styles"; +import type { + OptionEditorGroup, + OptionEditorSwatch, + OptionEditorValue, +} from "../types"; import { DisplayTypeSelector } from "./style-selector"; import { SortableValue } from "./sortable-value"; interface ISortableOptionGroupProps { - group: ApiProductOption; + group: OptionEditorGroup; fieldId: string; onUpdateName: (name: string) => void; onUpdateDisplayType: (displayType: OptionDisplayType) => void; onDeleteGroup: () => void; onUpdateValueName: (valueIndex: number, name: string) => void; - onUpdateValueSwatch: (valueIndex: number, swatch: ApiProductOptionSwatchInput) => void; + onUpdateValueSwatch: (valueIndex: number, swatch: OptionEditorSwatch) => void; onDeleteValue: (valueIndex: number) => void; onAddValue: () => void; - onReorderValues: (values: ApiProductOptionValue[]) => void; + onReorderValues: (values: OptionEditorValue[]) => void; } export const SortableOptionGroup = ({ @@ -84,6 +84,8 @@ export const SortableOptionGroup = ({ if (over && active.id !== over.id) { const oldIndex = group.values.findIndex((v) => v.id === active.id); const newIndex = group.values.findIndex((v) => v.id === over.id); + if (oldIndex < 0 || newIndex < 0) return; + const newValues = arrayMove(group.values, oldIndex, newIndex); onReorderValues(newValues); } @@ -92,7 +94,7 @@ export const SortableOptionGroup = ({ const menuItems = [ { key: "delete", - label: "Delete option", + label: Delete option, icon: , danger: true, onClick: onDeleteGroup, @@ -103,6 +105,7 @@ export const SortableOptionGroup = ({
    @@ -132,15 +137,24 @@ export const SortableOptionGroup = ({ align="center" onPointerDown={(e) => e.stopPropagation()} > - + @@ -128,10 +213,10 @@ export const EditOptionsModal = ({ initialGroups = MOCK_OPTION_GROUPS }: EditOpt onDragEnd={handleDragEnd} > f.id)} + items={fields.map((field) => field.id)} strategy={verticalListSortingStrategy} > - + {fields.map((field, groupIndex) => ( void; + onChange?: () => void; +} + +function moveArray(items: T[], oldIndex: number, newIndex: number): T[] { + const next = [...items]; + const [item] = next.splice(oldIndex, 1); + + if (!item) { + return items; + } + + next.splice(newIndex, 0, item); + return next; +} + +function normalizeValueSortIndexes( + values: OptionEditorValue[], +): OptionEditorValue[] { + return values.map((value, index) => ({ + ...value, + sortIndex: index, + })); +} + +function normalizeGroupSortIndexes( + groups: OptionEditorGroup[], +): OptionEditorGroup[] { + return groups.map((group, index) => ({ + ...group, + sortIndex: index, + values: normalizeValueSortIndexes(group.values), + })); +} + +function createEmptyValue(input: { + sortIndex: number; + displayType: OptionDisplayType; +}): OptionEditorValue { + return { + id: createTemporaryOptionValueId(), + name: "", + slug: "", + sortIndex: input.sortIndex, + swatch: + input.displayType === OptionDisplayType.Swatch + ? { ...DEFAULT_SWATCH } + : null, + }; } export const useEditOptionsForm = ({ defaultValues = { groups: [] }, - onSubmit, + onChange, }: UseEditOptionsFormProps = {}) => { - const { control, handleSubmit, watch, setValue, getValues } = + const { control, watch, setValue, getValues } = useForm({ - defaultValues, + defaultValues: { + groups: normalizeGroupSortIndexes(defaultValues.groups), + }, }); - const { fields, remove, append, move } = useFieldArray({ + const { fields, replace } = useFieldArray({ control, name: "groups", }); - const watchedGroups = watch("groups"); + const watchedGroups = watch("groups") ?? []; + + const notifyChange = useCallback(() => { + onChange?.(); + }, [onChange]); + + const replaceGroups = useCallback( + (groups: OptionEditorGroup[]) => { + replace(normalizeGroupSortIndexes(groups)); + notifyChange(); + }, + [notifyChange, replace], + ); const handleUpdateGroupName = useCallback( (groupIndex: number, name: string) => { - setValue(`groups.${groupIndex}.name`, name); + setValue(`groups.${groupIndex}.name`, name, { shouldDirty: true }); + notifyChange(); }, - [setValue] + [notifyChange, setValue], ); const handleUpdateGroupDisplayType = useCallback( (groupIndex: number, displayType: OptionDisplayType) => { - setValue(`groups.${groupIndex}.displayType`, displayType); + const group = getValues(`groups.${groupIndex}`); + const values = group.values.map((value) => ({ + ...value, + swatch: + displayType === OptionDisplayType.Swatch + ? value.swatch ?? { ...DEFAULT_SWATCH } + : value.swatch, + })); + + setValue(`groups.${groupIndex}`, { + ...group, + displayType, + values, + }, { shouldDirty: true }); + notifyChange(); }, - [setValue] + [getValues, notifyChange, setValue], ); const handleDeleteGroup = useCallback( (groupIndex: number) => { - remove(groupIndex); + replaceGroups(getValues("groups").filter((_, index) => index !== groupIndex)); }, - [remove] + [getValues, replaceGroups], ); const handleUpdateValueName = useCallback( (groupIndex: number, valueIndex: number, name: string) => { - setValue(`groups.${groupIndex}.values.${valueIndex}.name`, name); + setValue(`groups.${groupIndex}.values.${valueIndex}.name`, name, { + shouldDirty: true, + }); + notifyChange(); }, - [setValue] + [notifyChange, setValue], ); const handleUpdateValueSwatch = useCallback( - (groupIndex: number, valueIndex: number, swatch: ApiProductOptionSwatchInput) => { - setValue(`groups.${groupIndex}.values.${valueIndex}.swatch`, swatch as ApiProductOption["values"][0]["swatch"]); + ( + groupIndex: number, + valueIndex: number, + swatch: OptionEditorSwatch, + ) => { + setValue( + `groups.${groupIndex}.values.${valueIndex}.swatch`, + swatch, + { shouldDirty: true }, + ); + notifyChange(); }, - [setValue] + [notifyChange, setValue], ); const handleDeleteValue = useCallback( (groupIndex: number, valueIndex: number) => { - const currentValues = getValues(`groups.${groupIndex}.values`); - const newValues = currentValues.filter((_, idx) => idx !== valueIndex); - setValue(`groups.${groupIndex}.values`, newValues); + const group = getValues(`groups.${groupIndex}`); + + if (group.values.length <= 1) { + return; + } + + const values = group.values.filter((_, index) => index !== valueIndex); + + setValue( + `groups.${groupIndex}.values`, + normalizeValueSortIndexes(values), + { shouldDirty: true }, + ); + notifyChange(); }, - [setValue, getValues] + [getValues, notifyChange, setValue], ); const handleAddValue = useCallback( (groupIndex: number) => { - const currentValues = getValues(`groups.${groupIndex}.values`); - const newValue: ApiProductOptionValue = { - __typename: "ProductOptionValue", - id: `val-${Date.now()}`, - name: "", - slug: "", - swatch: { ...DEFAULT_SWATCH, __typename: "ProductOptionSwatch", id: `swatch-${Date.now()}`, file: null, metadata: null }, - }; - setValue(`groups.${groupIndex}.values`, [...currentValues, newValue]); + const group = getValues(`groups.${groupIndex}`); + const currentValues = group.values; + const newValue = createEmptyValue({ + sortIndex: currentValues.length, + displayType: group.displayType, + }); + + setValue( + `groups.${groupIndex}.values`, + normalizeValueSortIndexes([...currentValues, newValue]), + { shouldDirty: true }, + ); + notifyChange(); }, - [setValue, getValues] + [getValues, notifyChange, setValue], ); const handleReorderValues = useCallback( - (groupIndex: number, values: ApiProductOptionValue[]) => { - setValue(`groups.${groupIndex}.values`, values); + (groupIndex: number, values: OptionEditorValue[]) => { + setValue( + `groups.${groupIndex}.values`, + normalizeValueSortIndexes(values), + { shouldDirty: true }, + ); + notifyChange(); }, - [setValue] + [notifyChange, setValue], ); const handleAddGroup = useCallback(() => { - const newGroup: ApiProductOption = { - __typename: "ProductOption", - id: `opt-${Date.now()}`, + const currentGroups = getValues("groups"); + const displayType = OptionDisplayType.Buttons; + const newGroup: OptionEditorGroup = { + id: createTemporaryOptionId(), name: "New Option", - slug: "new-option", - displayType: OptionDisplayType.Buttons, - values: [], + slug: "", + displayType, + sortIndex: currentGroups.length, + values: [ + createEmptyValue({ + sortIndex: 0, + displayType, + }), + ], }; - append(newGroup); - }, [append]); + + replaceGroups([...currentGroups, newGroup]); + }, [getValues, replaceGroups]); const handleMoveGroup = useCallback( (oldIndex: number, newIndex: number) => { - move(oldIndex, newIndex); - }, - [move] - ); - - const submitForm = useCallback( - (data: IEditOptionsFormValues) => { - onSubmit?.(data); + replaceGroups(moveArray(getValues("groups"), oldIndex, newIndex)); }, - [onSubmit] + [getValues, replaceGroups], ); return { fields, watchedGroups, - handleSubmit: handleSubmit(submitForm), handleUpdateGroupName, handleUpdateGroupDisplayType, handleDeleteGroup, diff --git a/admin/src/domains/inventory/products/modals/edit-options-modal/index.ts b/admin/src/domains/inventory/products/modals/edit-options-modal/index.ts index e6c8e1d97..9c3e411d0 100644 --- a/admin/src/domains/inventory/products/modals/edit-options-modal/index.ts +++ b/admin/src/domains/inventory/products/modals/edit-options-modal/index.ts @@ -1,12 +1,2 @@ export { EditOptionsModal } from "./edit-options-modal"; export { DISPLAY_TYPE_OPTIONS } from "./edit-options-modal.constants"; -export type { IEditOptionsFormValues } from "./edit-options-modal.schema"; -// Re-export API types for convenience -export type { - ApiProductOption, - ApiProductOptionValue, - ApiProductOptionSwatch, - ApiProductOptionSwatchInput, - OptionDisplayType, - SwatchType, -} from "@/graphql/types"; diff --git a/admin/src/domains/inventory/products/modals/edit-options-modal/types.ts b/admin/src/domains/inventory/products/modals/edit-options-modal/types.ts new file mode 100644 index 000000000..0d14c0562 --- /dev/null +++ b/admin/src/domains/inventory/products/modals/edit-options-modal/types.ts @@ -0,0 +1,30 @@ +import type { OptionDisplayType, SwatchType } from "@/graphql/types"; + +export interface OptionEditorSwatch { + swatchType: SwatchType; + colorOne?: string | null; + colorTwo?: string | null; + fileId?: string | null; + fileUrl?: string | null; + metadata?: unknown; +} + +export interface OptionEditorValue { + id: string; + apiId?: string; + apiSwatchId?: string; + name: string; + slug: string; + sortIndex: number; + swatch: OptionEditorSwatch | null; +} + +export interface OptionEditorGroup { + id: string; + apiId?: string; + name: string; + slug: string; + displayType: OptionDisplayType; + sortIndex: number; + values: OptionEditorValue[]; +} diff --git a/admin/src/domains/inventory/products/modals/edit-seo-modal/components/form-field.tsx b/admin/src/domains/inventory/products/modals/edit-seo-modal/components/form-field.tsx deleted file mode 100644 index 3521f0603..000000000 --- a/admin/src/domains/inventory/products/modals/edit-seo-modal/components/form-field.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ReactNode } from "react"; -import { Typography } from "antd"; -import { useStyles } from "../edit-seo-modal.styles"; - -interface IFormFieldProps { - label: string; - children: ReactNode; - isLast?: boolean; -} - -export const FormField = ({ label, children, isLast }: IFormFieldProps) => { - const { styles } = useStyles(); - - return ( -
    - - {label} - - {children} -
    - ); -}; diff --git a/admin/src/domains/inventory/products/modals/edit-seo-modal/components/image-upload.tsx b/admin/src/domains/inventory/products/modals/edit-seo-modal/components/image-upload.tsx deleted file mode 100644 index a5a55a13d..000000000 --- a/admin/src/domains/inventory/products/modals/edit-seo-modal/components/image-upload.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Typography, Image, Button, Flex } from "antd"; -import { UploadOutlined, DeleteOutlined } from "@ant-design/icons"; -import { useStyles } from "../edit-seo-modal.styles"; -import { useUploadMediaModal } from "@/domains/media/modals"; -import type { ApiFile } from "@/graphql/types"; - -interface IImageUploadProps { - value: ApiFile | null; - onChange: (file: ApiFile | null) => void; -} - -export const ImageUpload = ({ value, onChange }: IImageUploadProps) => { - const { styles } = useStyles(); - const { push: openUploadModal } = useUploadMediaModal(); - - const handleUploadClick = () => { - openUploadModal({ - accept: "image/*", - maxFiles: 1, - onUpload: (files: ApiFile[]) => { - if (files.length > 0) { - onChange(files[0]); - } - }, - }); - }; - - const handleRemove = () => { - onChange(null); - }; - - if (value) { - return ( -
    - {value.originalName -
    - ); - } - - return ( -
    { - if (e.key === "Enter" || e.key === " ") { - handleUploadClick(); - } - }} - > - - - - Upload OG Image - - - Recommended: 1200 x 630px - - -
    - ); -}; diff --git a/admin/src/domains/inventory/products/modals/edit-seo-modal/components/index.ts b/admin/src/domains/inventory/products/modals/edit-seo-modal/components/index.ts deleted file mode 100644 index 563b4a8be..000000000 --- a/admin/src/domains/inventory/products/modals/edit-seo-modal/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ImageUpload } from "./image-upload"; -export { FormField } from "./form-field"; diff --git a/admin/src/domains/inventory/products/modals/edit-seo-modal/edit-seo-modal.styles.ts b/admin/src/domains/inventory/products/modals/edit-seo-modal/edit-seo-modal.styles.ts deleted file mode 100644 index b5e4f0402..000000000 --- a/admin/src/domains/inventory/products/modals/edit-seo-modal/edit-seo-modal.styles.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createStyles } from "antd-style"; - -export const useStyles = createStyles(({ token }) => ({ - formItem: { - marginBottom: 16, - }, - formItemLast: { - marginBottom: 0, - }, - label: { - display: "block", - marginBottom: 4, - fontSize: token.fontSize, - }, - ogExtra: { - fontSize: 12, - }, - // Image upload - draggerIcon: { - fontSize: 32, - color: token.colorTextSecondary, - marginBottom: 8, - }, - uploadArea: { - padding: "24px 16px", - border: `2px dashed ${token.colorBorder}`, - borderRadius: token.borderRadius, - cursor: "pointer", - transition: "all 0.2s ease", - "&:hover": { - borderColor: token.colorPrimary, - background: token.colorPrimaryBg, - }, - }, - imagePreviewContainer: { - position: "relative", - borderRadius: 8, - overflow: "hidden", - }, - imagePreview: { - width: "100%", - height: 120, - objectFit: "cover", - }, - imageRemoveButton: { - position: "absolute", - top: 8, - right: 8, - }, -})); diff --git a/admin/src/domains/inventory/products/modals/edit-seo-modal/edit-seo-modal.tsx b/admin/src/domains/inventory/products/modals/edit-seo-modal/edit-seo-modal.tsx index ac97441db..146fc0fc1 100644 --- a/admin/src/domains/inventory/products/modals/edit-seo-modal/edit-seo-modal.tsx +++ b/admin/src/domains/inventory/products/modals/edit-seo-modal/edit-seo-modal.tsx @@ -1,161 +1,35 @@ "use client"; -import { useForm, Controller } from "react-hook-form"; -import { Input, Typography, Flex } from "antd"; -import { - useModalStackContext, - ModalLayout, - ModalHeader, -} from "@/layouts/modals"; -import { Paper, PaperHeader } from "@/ui-kit/paper"; -import { SeoPreview } from "../../components/seo"; +import { useModalStackContext } from "@/layouts/modals"; +import { EntitySeoModal } from "@/domains/inventory/components/entity-edit-forms"; import type { IEditSeoModalPayload } from "../../modals"; -import { useStyles } from "./edit-seo-modal.styles"; -import { - SEO_TITLE_MAX, - SEO_DESCRIPTION_MAX, - OG_TITLE_MAX, - OG_DESCRIPTION_MAX, -} from "./edit-seo-modal.constants"; -import { ImageUpload, FormField } from "./components"; -import type { ISeoFormValues } from "./types"; export const EditSeoModal = () => { - const { styles } = useStyles(); const { payload, pop } = useModalStackContext(); const typedPayload = payload as IEditSeoModalPayload; - - const { control, handleSubmit, watch } = useForm({ - defaultValues: { - seoTitle: typedPayload.seoTitle || "", - seoDescription: typedPayload.seoDescription || "", - ogTitle: typedPayload.ogTitle || "", - ogDescription: typedPayload.ogDescription || "", - ogImage: typedPayload.ogImage || null, - }, - }); - - const values = watch(); - const baseUrl = typedPayload.baseUrl || "yourstore.com"; - - const onSubmit = (formValues: ISeoFormValues) => { - typedPayload.onSave?.(formValues); - pop(); - }; + const productSlug = typedPayload.productSlug || "product"; return ( - - } - > - - - - - ( - - )} - /> - - - ( - - )} - /> - - - - - - Facebook, LinkedIn, etc. - - } - /> - - ( - - )} - /> - - - ( - - )} - /> - - - ( - - )} - /> - - - - - - - - - + title="Edit SEO & Social" + initialValues={{ + seoTitle: typedPayload.seoTitle || "", + seoDescription: typedPayload.seoDescription || "", + ogTitle: typedPayload.ogTitle || "", + ogDescription: typedPayload.ogDescription || "", + ogImage: typedPayload.ogImage || null, + }} + entityTitle={typedPayload.productTitle || ""} + entitySlug={productSlug} + previewPath={`products › ${productSlug}`} + baseUrl={typedPayload.baseUrl || "yourstore.com"} + metaTitleTestId="edit-seo-meta-title-input" + metaDescriptionTestId="edit-seo-meta-description-input" + ogTitleTestId="edit-seo-og-title-input" + ogDescriptionTestId="edit-seo-og-description-input" + onClose={pop} + onSubmit={(formValues) => typedPayload.onSave?.(formValues)} + /> ); }; diff --git a/admin/src/domains/inventory/products/modals/edit-seo-modal/index.ts b/admin/src/domains/inventory/products/modals/edit-seo-modal/index.ts index aea598160..0244fdee4 100644 --- a/admin/src/domains/inventory/products/modals/edit-seo-modal/index.ts +++ b/admin/src/domains/inventory/products/modals/edit-seo-modal/index.ts @@ -1,2 +1 @@ export { EditSeoModal } from "./edit-seo-modal"; -export type { ISeoFormValues, IPreviewProps } from "./types"; diff --git a/admin/src/domains/inventory/products/modals/edit-seo-modal/types.ts b/admin/src/domains/inventory/products/modals/edit-seo-modal/types.ts deleted file mode 100644 index f50094c69..000000000 --- a/admin/src/domains/inventory/products/modals/edit-seo-modal/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ApiFile } from "@/graphql/types"; - -export interface ISeoFormValues { - seoTitle: string; - seoDescription: string; - ogTitle: string; - ogDescription: string; - ogImage: ApiFile | null; -} - -export interface IPreviewProps { - values: ISeoFormValues; - productTitle: string; - baseUrl: string; - slug?: string; -} diff --git a/admin/src/domains/inventory/products/modals/edit-tags-modal/edit-tags-modal.tsx b/admin/src/domains/inventory/products/modals/edit-tags-modal/edit-tags-modal.tsx index b7596e97e..279bc6359 100644 --- a/admin/src/domains/inventory/products/modals/edit-tags-modal/edit-tags-modal.tsx +++ b/admin/src/domains/inventory/products/modals/edit-tags-modal/edit-tags-modal.tsx @@ -10,21 +10,18 @@ import { Divider, message, } from "antd"; -import { - SearchOutlined, - PlusOutlined, - TagOutlined, -} from "@ant-design/icons"; +import { LuSearch as SearchOutlined, LuPlus as PlusOutlined, LuTag as TagOutlined } from "react-icons/lu"; import { useModalStackContext, ModalLayout, ModalHeader, } from "@/layouts/modals"; import { Paper } from "@/ui-kit/paper"; -import type { IEditTagsModalPayload, ITag } from "../../modals"; +import type { ApiTag } from "@/graphql/types"; +import type { IEditTagsModalPayload } from "../../modals"; import { useStyles } from "./edit-tags-modal.styles"; import type { IEditTagsModalProps } from "./types"; -import { mockTags } from "@/mocks/products/tags"; +import { mockApiTags } from "@/mocks/products/data"; export type { IEditTagsModalProps }; @@ -34,7 +31,7 @@ export const EditTagsModal = () => { const { selectedTagIds: initialTagIds, - availableTags = mockTags, + availableTags = mockApiTags, onSave, onCreateTag, } = (payload as IEditTagsModalPayload) || {}; @@ -51,8 +48,8 @@ export const EditTagsModal = () => { const term = searchTerm.toLowerCase(); return availableTags.filter( (tag) => - tag.title.toLowerCase().includes(term) || - tag.slug.toLowerCase().includes(term) + tag.name.toLowerCase().includes(term) || + tag.handle.toLowerCase().includes(term) ); }, [availableTags, searchTerm]); @@ -67,7 +64,7 @@ export const EditTagsModal = () => { () => selectedIds .map((id) => tagMap.get(id)) - .filter((t): t is ITag => t !== undefined), + .filter((t): t is ApiTag => t !== undefined), [selectedIds, tagMap] ); @@ -104,7 +101,7 @@ export const EditTagsModal = () => { setSelectedIds((prev) => [...prev, newTag.id]); setSearchTerm(""); markDirty(); - message.success(`Tag "${newTag.title}" created`); + message.success(`Tag "${newTag.name}" created`); } catch { message.error("Failed to create tag"); } finally { @@ -118,7 +115,7 @@ export const EditTagsModal = () => { const term = searchTerm.toLowerCase(); return !availableTags.some( (tag) => - tag.title.toLowerCase() === term || tag.slug.toLowerCase() === term + tag.name.toLowerCase() === term || tag.handle.toLowerCase() === term ); }, [searchTerm, availableTags]); @@ -154,12 +151,11 @@ export const EditTagsModal = () => { {selectedTags.map((tag) => ( handleRemoveTag(tag.id)} className={styles.selectedTag} > - {tag.title} + {tag.name} ))}
    @@ -230,19 +226,12 @@ export const EditTagsModal = () => { onKeyDown={(e) => e.key === "Enter" && handleToggle(tag.id)} >
    - {tag.color ? ( -
    - ) : ( - - )} + - {tag.title} + {tag.name} - #{tag.slug} + #{tag.handle}
    void; - onCreateTag?: (title: string) => Promise; + onCreateTag?: (name: string) => Promise; } diff --git a/admin/src/domains/inventory/products/modals/edit-title-modal/edit-title-modal.styles.ts b/admin/src/domains/inventory/products/modals/edit-title-modal/edit-title-modal.styles.ts deleted file mode 100644 index 2bbaffaa1..000000000 --- a/admin/src/domains/inventory/products/modals/edit-title-modal/edit-title-modal.styles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createStyles } from "antd-style"; - -export const useStyles = createStyles(({ token }) => ({ - formItem: { - marginBottom: 16, - }, - formItemLast: { - marginBottom: 0, - }, - label: { - display: "block", - marginBottom: 4, - fontSize: token.fontSize, - }, - error: { - color: token.colorError, - fontSize: token.fontSizeSM, - marginTop: 4, - }, - extra: { - color: token.colorTextSecondary, - fontSize: token.fontSizeSM, - marginTop: 4, - }, -})); diff --git a/admin/src/domains/inventory/products/modals/edit-title-modal/edit-title-modal.tsx b/admin/src/domains/inventory/products/modals/edit-title-modal/edit-title-modal.tsx index 4b6a43b2c..f88b11042 100644 --- a/admin/src/domains/inventory/products/modals/edit-title-modal/edit-title-modal.tsx +++ b/admin/src/domains/inventory/products/modals/edit-title-modal/edit-title-modal.tsx @@ -1,131 +1,32 @@ "use client"; -import { useEffect } from "react"; -import { useForm, Controller } from "react-hook-form"; -import { Input, Typography } from "antd"; -import { - useModalStackContext, - ModalLayout, - ModalHeader, -} from "@/layouts/modals"; -import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useModalStackContext } from "@/layouts/modals"; +import { EntityIdentityModal } from "@/domains/inventory/components/entity-edit-forms"; import type { IProductEditTitleModalPayload } from "../../modals"; -import { useStyles } from "./edit-title-modal.styles"; -import type { IEditTitleForm } from "./types"; export const EditTitleModal = () => { - const { styles } = useStyles(); const { payload, pop } = useModalStackContext(); const typedPayload = payload as IProductEditTitleModalPayload; - const { - control, - handleSubmit, - setValue, - watch, - formState: { errors }, - } = useForm({ - defaultValues: { - title: typedPayload.title || "", - handle: typedPayload.handle || "", - }, - }); - - const titleValue = watch("title"); - - useEffect(() => { - const handle = titleValue - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - setValue("handle", handle); - }, [titleValue, setValue]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - pop(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [pop]); - - const onSubmit = (values: IEditTitleForm) => { - typedPayload.onSave?.(values); - pop(); - }; - return ( - - } - > - - -
    -
    - - Title - - ( - - )} - /> - {errors.title && ( - - {errors.title.message} - - )} -
    - -
    - - Handle - - ( - - )} - /> - {errors.handle ? ( - - {errors.handle.message} - - ) : ( - - URL-friendly identifier for this product - - )} -
    -
    -
    -
    + title="Edit Title" + sectionTitle="Product Title" + initialValues={{ + title: typedPayload.title || "", + handle: typedPayload.handle || "", + }} + primaryLabel="Title" + primaryRequiredMessage="Title is required" + primaryPlaceholder="Product title" + primaryTestId="edit-title-title-input" + handleAddonBefore="/" + handlePlaceholder="product-handle" + handleTestId="edit-title-handle-input" + handleHelpText="URL-friendly identifier for this product" + onClose={pop} + onSubmit={(values) => typedPayload.onSave?.(values)} + /> ); }; diff --git a/admin/src/domains/inventory/products/modals/edit-title-modal/types.ts b/admin/src/domains/inventory/products/modals/edit-title-modal/types.ts deleted file mode 100644 index ad550dd85..000000000 --- a/admin/src/domains/inventory/products/modals/edit-title-modal/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IEditTitleForm { - title: string; - handle: string; -} diff --git a/admin/src/domains/inventory/products/modals/price-history-modal/components/change-log-section.tsx b/admin/src/domains/inventory/products/modals/price-history-modal/components/change-log-section.tsx index c8f98137b..2a0bad1e4 100644 --- a/admin/src/domains/inventory/products/modals/price-history-modal/components/change-log-section.tsx +++ b/admin/src/domains/inventory/products/modals/price-history-modal/components/change-log-section.tsx @@ -1,6 +1,6 @@ import { Paper, PaperHeader } from "@/ui-kit/paper"; -import { PriceTimeline } from "../../../components/pricing/components"; -import type { ApiVariantPriceConnection } from "../../../components/pricing/types"; +import type { ApiVariantPriceConnection } from "@/graphql/types"; +import { PriceTimeline } from "../../../components/pricing/components/price-timeline"; import { useStyles } from "../price-history-modal.styles"; interface IChangeLogSectionProps { @@ -13,8 +13,11 @@ export const ChangeLogSection = ({ history }: IChangeLogSectionProps) => { return ( -
    - +
    +
    ); diff --git a/admin/src/domains/inventory/products/modals/price-history-modal/components/overview-section.tsx b/admin/src/domains/inventory/products/modals/price-history-modal/components/overview-section.tsx index 9c1f16bac..1054c84ba 100644 --- a/admin/src/domains/inventory/products/modals/price-history-modal/components/overview-section.tsx +++ b/admin/src/domains/inventory/products/modals/price-history-modal/components/overview-section.tsx @@ -1,23 +1,28 @@ import { Typography, Flex, Button } from "antd"; -import { DownOutlined } from "@ant-design/icons"; +import { LuChevronDown as DownOutlined } from "react-icons/lu"; import { Paper } from "@/ui-kit/paper"; import { KPITile } from "@/ui-kit/kpi-tile"; -import { PeriodSwitch, PERIODS, type Period } from "../../../components/period-switch"; -import { - PriceChart, - PriceChangeIndicator, - ScrollableDropdown, -} from "../../../components/pricing/components"; +import { PeriodSwitch } from "../../../components/period-switch"; +import { PERIODS, type Period } from "../../../utils/periods"; +import { PriceChangeIndicator } from "../../../components/pricing/components/price-change-indicator"; +import { PriceChart } from "../../../components/pricing/components/price-chart"; +import { ScrollableDropdown } from "../../../components/pricing/components/scrollable-dropdown"; import type { ApiVariantConnection, + ApiVariantPrice, ApiVariantPriceConnection, ApiVariantPriceHistoryStatistics, -} from "../../../components/pricing/types"; + CurrencyCode, +} from "@/graphql/types"; +import { + formatPrice, + useVariantPrice, +} from "../../../utils/price-formatting"; import { useStyles } from "../price-history-modal.styles"; interface IOverviewSectionProps { - currentPrice: number; - compareAtPrice: number | null; + currentPrice: ApiVariantPrice | null; + currency?: CurrencyCode | null; history: ApiVariantPriceConnection; stats: ApiVariantPriceHistoryStatistics | null; variants: ApiVariantConnection; @@ -27,12 +32,25 @@ interface IOverviewSectionProps { isLoadingVariants: boolean; period: Period; onPeriodChange: (period: Period) => void; - formatPrice: (amount: number) => string; } +const VariantPriceLabel = ({ + price, +}: { + price: ApiVariantPrice | null | undefined; +}) => { + const formattedPrice = useVariantPrice(price); + + return ( + + {formattedPrice} + + ); +}; + export const OverviewSection = ({ currentPrice, - compareAtPrice, + currency, history, stats, variants, @@ -42,31 +60,37 @@ export const OverviewSection = ({ isLoadingVariants, period, onPeriodChange, - formatPrice, }: IOverviewSectionProps) => { const { styles } = useStyles(); + const formattedCurrentPrice = useVariantPrice(currentPrice); const selectedVariant = variants.edges.find( (e) => e.node.id === selectedVariantId )?.node; + const currentPriceAmount = currentPrice?.amountMinor ?? 0; + const compareAtPrice = currentPrice?.compareAtMinor ?? null; const previousPrice = history.edges.length > 1 ? history.edges[1]?.node.amountMinor : null; const variantMenuItems = variants.edges.map((edge) => ({ key: edge.node.id, label: ( - + {edge.node.title ?? "Untitled"} - - {formatPrice(edge.node.price?.amountMinor ?? 0)} - + ), })); return ( +
    {variants.edges.length > 1 && (
    -
    @@ -152,7 +185,7 @@ export const OverviewSection = ({ - +
    + +
    +
    ); diff --git a/admin/src/domains/inventory/products/modals/price-history-modal/index.ts b/admin/src/domains/inventory/products/modals/price-history-modal/index.ts index 5df824b67..b482cde0c 100644 --- a/admin/src/domains/inventory/products/modals/price-history-modal/index.ts +++ b/admin/src/domains/inventory/products/modals/price-history-modal/index.ts @@ -1,2 +1 @@ export { PriceHistoryModal } from "./price-history-modal"; -export type { IPriceHistoryModalPayload } from "./types"; diff --git a/admin/src/domains/inventory/products/modals/price-history-modal/price-history-modal.tsx b/admin/src/domains/inventory/products/modals/price-history-modal/price-history-modal.tsx index 36df7e2d3..4263b66f4 100644 --- a/admin/src/domains/inventory/products/modals/price-history-modal/price-history-modal.tsx +++ b/admin/src/domains/inventory/products/modals/price-history-modal/price-history-modal.tsx @@ -2,16 +2,16 @@ import { useEffect } from "react"; import { Flex, Spin } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; +import { LuLoaderCircle as LoadingOutlined } from "react-icons/lu"; import { useModalStackContext, ModalLayout, ModalHeader, } from "@/layouts/modals"; -import { formatPrice as defaultFormatPrice } from "../../components/pricing/utils"; -import { usePricingWidget } from "../../components/pricing/use-pricing-widget"; -import type { Period } from "../../components/period-switch"; -import { OverviewSection, ChangeLogSection } from "./components"; +import { useProductPricingWidget } from "../../hooks"; +import type { Period } from "../../utils/periods"; +import { ChangeLogSection } from "./components/change-log-section"; +import { OverviewSection } from "./components/overview-section"; import type { IPriceHistoryModalPayload } from "./types"; export const PriceHistoryModal = () => { @@ -28,13 +28,16 @@ export const PriceHistoryModal = () => { selectVariant, period, setPeriod, - } = usePricingWidget(typedPayload.productId); - - const formatPrice = typedPayload.formatPrice || defaultFormatPrice; + } = useProductPricingWidget({ + productId: typedPayload.productId, + }); // Extract pricing data from widget response - const currentPrice = data?.currentPrice?.amountMinor ?? 0; - const compareAtPrice = data?.currentPrice?.compareAtMinor ?? null; + const currentPrice = data?.currentPrice ?? null; + const currency = + data?.currentPrice?.currency ?? + data?.currentCostPrice?.currency ?? + data?.statistics.currency; const history = data?.history ?? { __typename: "VariantPriceConnection" as const, edges: [], @@ -71,7 +74,7 @@ export const PriceHistoryModal = () => { } > - } size="large" /> + } size="large" /> ); @@ -91,7 +94,7 @@ export const PriceHistoryModal = () => { > { isLoadingVariants={isLoadingVariants} period={period} onPeriodChange={setPeriod} - formatPrice={formatPrice} /> diff --git a/admin/src/domains/inventory/products/modals/price-history-modal/types.ts b/admin/src/domains/inventory/products/modals/price-history-modal/types.ts index f985c821d..50bf99118 100644 --- a/admin/src/domains/inventory/products/modals/price-history-modal/types.ts +++ b/admin/src/domains/inventory/products/modals/price-history-modal/types.ts @@ -1,6 +1,3 @@ -import type { CurrencyCode } from "../../components/pricing/types"; - export interface IPriceHistoryModalPayload { productId: string; - formatPrice?: (amount: number, currency?: CurrencyCode) => string; } diff --git a/admin/src/domains/inventory/products/modals/product-modal/product-modal.tsx b/admin/src/domains/inventory/products/modals/product-modal/product-modal.tsx index 9d257279b..214dfda63 100644 --- a/admin/src/domains/inventory/products/modals/product-modal/product-modal.tsx +++ b/admin/src/domains/inventory/products/modals/product-modal/product-modal.tsx @@ -1,32 +1,80 @@ "use client"; -import { useState, useEffect } from "react"; -import { Flex, Skeleton } from "antd"; +import { useMemo, useState, useEffect } from "react"; +import { Alert, Flex, Skeleton } from "antd"; import { useModalStackContext, ModalLayout } from "@/layouts/modals"; -import { ProductDetailsCard } from "../../components/product-details-card"; -import { - mockVariableProduct, - mockSimpleProduct, - productDetailsMockData, - getMockVariantsTableData, -} from "@/mocks/products"; +import { ProductDetailsCard } from "../../components/product-details-card/product-details-card"; +import { productDetailsMockData } from "../../components/product-details-card/supplemental-data"; +import { useProduct } from "../../hooks"; + +const VARIANTS_PAGE_SIZE = 10; export const ProductModal = () => { const { payload, pop, forcePop } = useModalStackContext(); - const [loading, setLoading] = useState(true); - const [variantsPage, setVariantsPage] = useState(1); + const [variantsPageIndex, setVariantsPageIndex] = useState(0); + const [variantCursorHistory, setVariantCursorHistory] = useState< + Array + >([null]); + const [paginationEntityId, setPaginationEntityId] = useState( + null, + ); + const entityId = + payload.entityId === undefined || payload.entityId === null + ? null + : String(payload.entityId); - const product = payload.simple ? mockSimpleProduct : mockVariableProduct; - const variantsTableData = getMockVariantsTableData(variantsPage, 10, 25); + const variantsAfter = + paginationEntityId === entityId + ? variantCursorHistory[variantsPageIndex] ?? null + : null; + const { product, loading, error, refetch } = useProduct({ + id: entityId, + variantsFirst: VARIANTS_PAGE_SIZE, + variantsAfter, + }); + const isVariantsPageLoading = loading && !!product; const handleVariantsPageChange = (direction: "next" | "prev") => { - setVariantsPage((prev) => (direction === "next" ? prev + 1 : prev - 1)); + if (!product || isVariantsPageLoading) { + return; + } + + if (direction === "next") { + const endCursor = product.variants.pageInfo.endCursor; + + if (!endCursor) { + return; + } + + setVariantCursorHistory((current) => { + const next = current.slice(0, variantsPageIndex + 1); + next[variantsPageIndex + 1] = endCursor; + return next; + }); + setVariantsPageIndex((current) => current + 1); + return; + } + + setVariantsPageIndex((current) => Math.max(0, current - 1)); }; + const variantsTableData = useMemo(() => { + if (!product) { + return undefined; + } + + return { + variants: product.variants.edges.map((edge) => edge.node), + pageInfo: product.variants.pageInfo, + totalCount: product.variants.totalCount, + }; + }, [product]); + useEffect(() => { - const timer = setTimeout(() => setLoading(false), 300); - return () => clearTimeout(timer); - }, []); + setPaginationEntityId(entityId); + setVariantsPageIndex(0); + setVariantCursorHistory([null]); + }, [entityId]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -39,7 +87,7 @@ export const ProductModal = () => { }, [pop]); const renderContent = () => { - if (loading) { + if (loading && !product) { return ( @@ -47,13 +95,30 @@ export const ProductModal = () => { ); } + if (error && !product) { + return ( + + + + ); + } + + if (!product) { + return ( + + + + ); + } + return ( console.log("Edit section:", section)} onVariantsPageChange={handleVariantsPageChange} + isVariantsPageLoading={isVariantsPageLoading} + onProductRefresh={refetch} /> ); }; diff --git a/admin/src/domains/inventory/products/page/filter-schema.ts b/admin/src/domains/inventory/products/page/filter-schema.ts index 2a36218c2..9dbd6fe4f 100644 --- a/admin/src/domains/inventory/products/page/filter-schema.ts +++ b/admin/src/domains/inventory/products/page/filter-schema.ts @@ -1,68 +1 @@ -import { - FilterType, - numberOperators, - stringOperators, - enumOperators, - type IFilterSchema, -} from "@/layouts/filters"; - -export const filterSchema: IFilterSchema[] = [ - { - key: "status", - label: "Status", - description: "Filter by product status", - type: FilterType.Enum, - operators: enumOperators, - payloadKey: "status", - options: [ - { label: "Published", value: "published" }, - { label: "Draft", value: "draft" }, - ], - }, - { - key: "category", - label: "Category", - description: "Filter by category", - type: FilterType.Enum, - operators: enumOperators, - payloadKey: "category", - options: [ - { label: "Phone", value: "Phone" }, - { label: "Laptop", value: "Laptop" }, - { label: "Audio", value: "Audio" }, - { label: "Gaming", value: "Gaming" }, - { label: "Accessory", value: "Accessory" }, - ], - }, - { - key: "brand", - label: "Brand", - description: "Filter by brand", - type: FilterType.Enum, - operators: enumOperators, - payloadKey: "brand", - options: [ - { label: "Apple", value: "Apple" }, - { label: "Samsung", value: "Samsung" }, - { label: "Sony", value: "Sony" }, - { label: "Microsoft", value: "Microsoft" }, - { label: "Logitech", value: "Logitech" }, - ], - }, - { - key: "inventory", - label: "Inventory", - description: "Filter by inventory quantity", - type: FilterType.Number, - operators: numberOperators, - payloadKey: "inventory", - }, - { - key: "name", - label: "Name", - description: "Filter by product name", - type: FilterType.String, - operators: stringOperators, - payloadKey: "name", - }, -]; +export { productLikeBaseFilterSchema as filterSchema } from "../list-page"; diff --git a/admin/src/domains/inventory/products/page/page-config.ts b/admin/src/domains/inventory/products/page/page-config.ts new file mode 100644 index 000000000..0e232f803 --- /dev/null +++ b/admin/src/domains/inventory/products/page/page-config.ts @@ -0,0 +1,63 @@ +import type { + FilterTransformer, + SortFieldMapping, + UsePageConfigReturn, +} from "@/hooks"; +import type { + ApiProductOrderByInput, + ApiProductWhereInput, +} from "@/graphql/types"; +import { ProductOrderField } from "@/graphql/types"; +import { + buildProductLikeQueryVariables, + buildProductLikeSearchCondition, + createProductLikeFilterTransformers, + createProductLikeSortFieldMapping, + toProductLikeQueryVariables, +} from "../list-page"; +import type { ProductsQueryVariables } from "../graphql/operation-types"; + +export const productSortFieldMapping: SortFieldMapping = + createProductLikeSortFieldMapping({ + Name: ProductOrderField.Name, + MinPriceMinor: ProductOrderField.MinPriceMinor, + MaxPriceMinor: ProductOrderField.MaxPriceMinor, + PrimaryCategoryName: ProductOrderField.PrimaryCategoryName, + BrandName: ProductOrderField.BrandName, + }); + +export const buildProductSearchCondition = + buildProductLikeSearchCondition; + +export const productFilterTransformers: Record< + string, + FilterTransformer +> = createProductLikeFilterTransformers(); + +export function buildProductsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): ProductsQueryVariables { + return buildProductLikeQueryVariables< + ProductsQueryVariables, + ApiProductWhereInput, + ProductOrderField, + ApiProductOrderByInput + >(pageConfig); +} + +export function toProductsQueryVariables( + pageConfig: Pick< + UsePageConfigReturn, + "first" | "after" | "last" | "before" | "where" | "orderBy" + >, +): ProductsQueryVariables { + return toProductLikeQueryVariables< + ProductsQueryVariables, + ApiProductWhereInput, + ProductOrderField, + ApiProductOrderByInput + >(pageConfig); +} diff --git a/admin/src/domains/inventory/products/page/page.tsx b/admin/src/domains/inventory/products/page/page.tsx index fb6c3e235..d4aa53f2c 100644 --- a/admin/src/domains/inventory/products/page/page.tsx +++ b/admin/src/domains/inventory/products/page/page.tsx @@ -1,8 +1,8 @@ "use client"; import { useState, useMemo, useRef, useCallback } from "react"; -import { Image, Typography, Flex, Button, Tag } from "antd"; -import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons"; +import { Alert, App, Typography, Flex, Button } from "antd"; +import { LuPlus as PlusOutlined, LuPencil as EditOutlined, LuTrash2 as DeleteOutlined, LuImage as PictureOutlined } from "react-icons/lu"; import { AgGridReact } from "ag-grid-react"; import { useModalStack } from "@/layouts/modals"; import { @@ -15,24 +15,40 @@ import { } from "ag-grid-community"; import type { CustomCellRendererProps } from "ag-grid-react"; import { DataLayout } from "@/layouts/data"; -import { useFilters, FilterWidget } from "@/layouts/filters"; +import { FilterWidget } from "@/layouts/filters"; import { CursorPagination } from "@/ui-kit/cursor-pagination"; +import { FloatingPanelStack } from "@/ui-kit/floating-panel-stack"; +import type { ActionConfig } from "@/ui-kit/floating-panel-stack/core/types"; +import type { PanelConfig } from "@/ui-kit/floating-panel-stack/data-page/floating-panel-stack"; import { - FloatingPanelStack, - type PanelConfig, - type ActionConfig, -} from "@/ui-kit/floating-panel-stack"; -import { - useGridState, - useGridSort, useAgGridTheme, useAgGridRowSelection, } from "@/hooks"; +import { useInventoryRelayListPage } from "@/domains/inventory/hooks"; import { filterSchema } from "./filter-schema"; -import { useProducts } from "../hooks"; -import type { IProductListItem } from "@/mocks/products/products-list"; +import { + buildProductSearchCondition, + buildProductsQueryVariables, + productFilterTransformers, + productSortFieldMapping, +} from "./page-config"; +import { useDeleteProduct, useProducts } from "../hooks"; import { useBulkEditorStore } from "../modals/bulk-editor-modal"; import { useProductCreateModal } from "../modals"; +import type { ApiProduct, ApiProductWhereInput } from "@/graphql/types"; +import { ProductOrderField } from "@/graphql/types"; +import { useDefaultCurrency } from "@/domains/workspace"; +import { + getProductBrandName, + getProductMaxPriceAmount, + getProductMinPriceAmount, + getProductPrimaryCategoryName, + getProductTotalAvailable, + getProductThumbnailFile, +} from "../utils/api-product-display"; +import { formatPrice } from "../utils/price-formatting"; +import { Dash } from "@/shared/components/editor-grid"; +import { TableCoverImage } from "@/shared/components/table-cover-image"; ModuleRegistry.registerModules([ AllCommunityModule, @@ -42,58 +58,123 @@ ModuleRegistry.registerModules([ // Cell Renderers const ProductCellRenderer = ( - props: CustomCellRendererProps, + props: CustomCellRendererProps, ) => { const { data } = props; if (!data) return null; + const thumbnail = getProductThumbnailFile(data); return ( - - {data.name} + } /> - {data.name} + {data.title} ); }; -const StatusCellRenderer = ( - props: CustomCellRendererProps, +const TextCellRenderer = ( + props: CustomCellRendererProps & { + testIdSuffix?: string; + }, +) => { + const { data, value, testIdSuffix } = props; + + return ( + + {value ?? } + + ); +}; + +const PriceCellRenderer = ( + props: CustomCellRendererProps & { + currency?: string | null; + testIdSuffix?: string; + }, ) => { - const { value } = props; - const config: Record = { - published: { color: "success", label: "Published" }, - draft: { color: "default", label: "Draft" }, - }; - const { color, label } = config[value] || config.draft; - return {label}; + const { data, value, currency, testIdSuffix } = props; + const displayCurrency = data?.priceRange?.currency ?? currency; + + return ( + + {value !== null && value !== undefined && displayCurrency + ? formatPrice(value, displayCurrency) + : } + + ); }; -const InventoryCellRenderer = ( - props: CustomCellRendererProps, +const StockCellRenderer = ( + props: CustomCellRendererProps, ) => { - const { value } = props; - if (value === 0) { - return 0 in stock; - } - return {value} in stock; + const { data, value } = props; + + return ( + + {value} in stock + + ); }; export default function ProductsPage() { const agGridTheme = useAgGridTheme(); - const gridRef = useRef>(null); - const [searchValue, setSearchValue] = useState(""); + const { message } = App.useApp(); + const gridRef = useRef>(null); const [selectedCount, setSelectedCount] = useState(0); - const { widgetProps } = useFilters({ schema: filterSchema }); + const defaultCurrency = useDefaultCurrency(); const { push } = useModalStack(); - const { data: products } = useProducts(); - const { initialState, onStateUpdated } = useGridState({ + const { + pageConfig, + items: products, + totalCount, + pageInfo, + loading, + error, + refetch, + handleNextPage, + handlePrevPage, + } = useInventoryRelayListPage< + ApiProduct, + ApiProductWhereInput, + ProductOrderField, + ReturnType, + ReturnType + >({ + gridRef, storageKey: "products-grid-state", + filterSchema, + sortFieldMapping: productSortFieldMapping, + defaultPageSize: 20, + buildSearchCondition: buildProductSearchCondition, + filterTransformers: productFilterTransformers, + buildQueryVariables: buildProductsQueryVariables, + useListQuery: useProducts, + getItems: (result) => result.products, }); + const { deleteProduct, loading: deletingProducts } = useDeleteProduct(); // Bulk editor store const setSelectedProducts = useBulkEditorStore((s) => s.setSelectedProducts); @@ -101,23 +182,16 @@ export default function ProductsPage() { // Create product modal const { push: pushCreateModal } = useProductCreateModal(); - const { onSortChanged } = useGridSort({ - gridRef, - onSortChange: (model) => { - // TODO: Replace with actual API call - console.log("Sort changed:", model); - }, - }); - // Row selection with checkbox isolation const { rowSelection, selectionColumnDef, onCellClicked } = - useAgGridRowSelection({ - onRowAction: () => push("product", { level: 1 }), + useAgGridRowSelection({ + onRowAction: (product) => + push("product", { entityId: product.id, mode: "view" }), }); // Handle selection changes const handleSelectionChanged = useCallback( - (event: SelectionChangedEvent) => { + (event: SelectionChangedEvent) => { const selectedRows = event.api.getSelectedRows(); setSelectedCount(selectedRows.length); }, @@ -133,25 +207,35 @@ export default function ProductsPage() { // Open bulk editor with selected products const handleBulkEdit = useCallback(() => { const selectedRows = gridRef.current?.api.getSelectedRows() || []; - // Map product IDs to bulk editor format (prod-1, prod-2, etc.) - // For demo purposes, we use the first 12 products from bulk editor mock - const bulkEditorIds = selectedRows.map( - (_, index) => `prod-${(index % 12) + 1}`, - ); - setSelectedProducts(bulkEditorIds); - push("bulk-editor", { productIds: bulkEditorIds }); + const productIds = selectedRows.map((product) => product.id); + setSelectedProducts(productIds); + push("bulk-editor", { productIds }); }, [setSelectedProducts, push]); // Delete selected products - const handleDeleteSelected = useCallback(() => { + const handleDeleteSelected = useCallback(async () => { const selectedRows = gridRef.current?.api.getSelectedRows() || []; - // TODO: Implement delete mutation - console.log( - "Delete products:", - selectedRows.map((r) => r.id), + + if (selectedRows.length === 0) { + return; + } + + const results = await Promise.all( + selectedRows.map((product) => deleteProduct({ id: product.id })), + ); + const firstError = results.flatMap((result) => result.userErrors)[0]; + + if (firstError) { + message.error(firstError.message); + return; + } + + message.success( + selectedRows.length === 1 ? "Product deleted" : "Products deleted", ); deselectAll(); - }, [deselectAll]); + await refetch(); + }, [deleteProduct, deselectAll, message, refetch]); // Build selection actions const selectionActions = useMemo( @@ -160,6 +244,8 @@ export default function ProductsPage() { key: "bulk-edit", label: "Bulk Edit", icon: , + disabled: true, + tooltip: "Bulk editor is still a mock-only flow", onClick: handleBulkEdit, }, { @@ -167,10 +253,11 @@ export default function ProductsPage() { label: "Delete", icon: , danger: true, + loading: deletingProducts, onClick: handleDeleteSelected, }, ], - [handleBulkEdit, handleDeleteSelected], + [deletingProducts, handleBulkEdit, handleDeleteSelected], ); // Build floating panels @@ -190,46 +277,73 @@ export default function ProductsPage() { return result; }, [selectedCount, selectionActions, deselectAll]); - const columnDefs = useMemo[]>( + const columnDefs = useMemo[]>( () => [ { headerName: "Product", - field: "name", + field: "title", cellRenderer: ProductCellRenderer, + flex: 2, minWidth: 300, }, { - headerName: "Status", - field: "status", - cellRenderer: StatusCellRenderer, - minWidth: 120, + headerName: "Min price", + colId: "minPriceMinor", + valueGetter: ({ data }) => + data ? getProductMinPriceAmount(data) : null, + cellRenderer: PriceCellRenderer, + cellRendererParams: { + currency: defaultCurrency, + testIdSuffix: "min-price", + }, + minWidth: 130, + }, + { + headerName: "Max price", + colId: "maxPriceMinor", + valueGetter: ({ data }) => + data ? getProductMaxPriceAmount(data) : null, + cellRenderer: PriceCellRenderer, + cellRendererParams: { + currency: defaultCurrency, + testIdSuffix: "max-price", + }, + minWidth: 130, }, { - headerName: "Inventory", - field: "inventory", - cellRenderer: InventoryCellRenderer, + headerName: "Stock", + colId: "stock", + valueGetter: ({ data }) => (data ? getProductTotalAvailable(data) : 0), + cellRenderer: StockCellRenderer, minWidth: 120, + sortable: false, }, { headerName: "Category", - field: "category", - minWidth: 120, + colId: "primaryCategoryName", + valueGetter: ({ data }) => + data ? getProductPrimaryCategoryName(data) : null, + cellRenderer: TextCellRenderer, + cellRendererParams: { testIdSuffix: "category" }, + minWidth: 180, }, { headerName: "Brand", - field: "brand", - minWidth: 120, + colId: "brand", + valueGetter: ({ data }) => (data ? getProductBrandName(data) : null), + cellRenderer: TextCellRenderer, + cellRendererParams: { testIdSuffix: "brand" }, + minWidth: 160, resizable: false, }, ], - [], + [defaultCurrency], ); const defaultColDef = useMemo( () => ({ resizable: true, sortable: true, - // Disable client-side sorting - server handles it comparator: () => 0, cellStyle: { display: "flex", alignItems: "center" }, }), @@ -242,11 +356,16 @@ export default function ProductsPage() { return ( } onClick={handleCreate}> + } @@ -254,11 +373,8 @@ export default function ProductsPage() { } /> @@ -271,11 +387,21 @@ export default function ProductsPage() { flexDirection: "column", }} > -
    - + {error && ( + + )} + +
    + ref={gridRef} theme={agGridTheme} rowData={products} + loading={loading || deletingProducts} columnDefs={columnDefs} defaultColDef={defaultColDef} getRowId={(params) => params.data.id} @@ -286,23 +412,28 @@ export default function ProductsPage() { suppressMovableColumns onCellClicked={onCellClicked} onSelectionChanged={handleSelectionChanged} + onSortChanged={pageConfig.onSortChanged} rowStyle={{ cursor: "pointer" }} - initialState={initialState} - onStateUpdated={onStateUpdated} - onSortChanged={onSortChanged} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} />
    {}} - onPrev={() => {}} - onPageSizeChange={() => {}} + name="products" + total={totalCount} + rangeStart={pageConfig.getRangeStart(products.length)} + rangeEnd={Math.min( + pageConfig.getRangeEnd(products.length), + totalCount, + )} + pageSize={pageConfig.pageSize} + pageSizeOptions={pageConfig.pageSizeOptions} + hasNext={pageInfo?.hasNextPage ?? false} + hasPrev={pageInfo?.hasPreviousPage ?? false} + onNext={handleNextPage} + onPrev={handlePrevPage} + onPageSizeChange={pageConfig.setPageSize} />
    diff --git a/admin/src/domains/inventory/products/register.tsx b/admin/src/domains/inventory/products/register.tsx deleted file mode 100644 index d9f42d9a7..000000000 --- a/admin/src/domains/inventory/products/register.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { registerModule } from "@/registry"; -import dynamic from "next/dynamic"; -import { ShoppingOutlined } from "@ant-design/icons"; - -registerModule({ - key: "products", - domain: "store", - sidebar: { - label: "Products", - icon: null, - order: 1, - }, - items: [ - { - key: "products-list", - path: "/:orgName/:storeName/products", - component: dynamic( - () => import("@/domains/inventory/products/page/page") - ), - }, - ], -}); diff --git a/admin/src/domains/inventory/products/utils/api-product-display.ts b/admin/src/domains/inventory/products/utils/api-product-display.ts new file mode 100644 index 000000000..aa4cc0689 --- /dev/null +++ b/admin/src/domains/inventory/products/utils/api-product-display.ts @@ -0,0 +1,126 @@ +import type { + ApiCategory, + ApiFile, + ApiProduct, + ApiProductOption, + ApiVariant, +} from "@/graphql/types"; + +export const getProductVariants = (product: ApiProduct): ApiVariant[] => + product.variants?.edges.map((edge) => edge.node) ?? []; + +export const getDefaultVariant = (product: ApiProduct): ApiVariant | null => { + const variants = getProductVariants(product); + + return variants.find((variant) => variant.isDefault) ?? variants[0] ?? null; +}; + +export const getProductMediaFiles = (product: ApiProduct): ApiFile[] => { + return [...(product.media ?? [])] + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((item) => item.file); +}; + +export const getProductThumbnailFile = ( + product: ApiProduct, +): ApiFile | null => getProductMediaFiles(product)[0] ?? null; + +export const getProductSku = (product: ApiProduct): string | null => + getDefaultVariant(product)?.inventoryItem?.sku ?? null; + +export const getProductPrimaryPriceAmount = ( + product: ApiProduct, +): number | null => getDefaultVariant(product)?.price?.amountMinor ?? null; + +export const getProductPriceAmounts = (product: ApiProduct): number[] => + getProductVariants(product) + .map((variant) => variant.price?.amountMinor ?? null) + .filter((amount): amount is number => amount !== null); + +export const getProductMinPriceAmount = ( + product: ApiProduct, +): number | null => product.priceRange?.minPriceAmount ?? null; + +export const getProductMaxPriceAmount = ( + product: ApiProduct, +): number | null => product.priceRange?.maxPriceAmount ?? null; + +export const getProductTotalAvailable = (product: ApiProduct): number => + getProductVariants(product).reduce( + (total, variant) => total + getVariantStockQuantity(variant), + 0, + ); + +export const getProductPrimaryCategoryName = ( + product: ApiProduct, +): string | null => getProductPrimaryCategory(product)?.name ?? null; + +export const getProductPrimaryCategory = ( + product: ApiProduct, +): ApiCategory | null => + product.primaryCategory ?? + (product.categoryAssignments ?? []).find((assignment) => assignment.isPrimary) + ?.category ?? + null; + +export const getProductCategories = (product: ApiProduct): ApiCategory[] => + (product.categoryAssignments ?? []).map((assignment) => assignment.category); + +export const getProductBrandName = (product: ApiProduct): string | null => { + return product.vendor?.name ?? null; +}; + +export const getVariantStockQuantity = (variant: ApiVariant): number => { + const inventoryItem = variant.inventoryItem; + + if (!inventoryItem) { + return 0; + } + + return ( + inventoryItem.totalAvailable ?? + (inventoryItem.stock ?? []).reduce( + (total, stock) => total + stock.quantityOnHand, + 0, + ) + ); +}; + +export const getSelectedOptionLabels = ( + productOptions: ApiProductOption[], + variant: ApiVariant, +): string[] => { + const optionsById = new Map( + productOptions.map((option) => [option.id, option]), + ); + + return variant.selectedOptions.map((selectedOption) => { + const option = optionsById.get(selectedOption.optionId); + const value = option?.values.find( + (candidate) => candidate.id === selectedOption.optionValueId, + ); + + if (option && value) { + return `${option.name}: ${value.name}`; + } + + return value?.name ?? option?.name ?? "Unknown option"; + }); +}; + +export const formatApiDate = (value: string | null | undefined): string => { + if (!value) { + return ""; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + }).format(date); +}; diff --git a/admin/src/domains/inventory/products/utils/periods.ts b/admin/src/domains/inventory/products/utils/periods.ts new file mode 100644 index 000000000..3e07c52ea --- /dev/null +++ b/admin/src/domains/inventory/products/utils/periods.ts @@ -0,0 +1,122 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); + +export interface DateRange { + gte: string; + lt: string; +} + +export interface PeriodConfig { + value: T; + label: string; + getDateRange: () => DateRange; +} + +export type Period = "7d" | "30d" | "90d" | "ytd" | "all"; + +export const getDateRangeForDays = (days: number): DateRange => { + const now = dayjs.utc(); + const lt = now.add(1, "day").startOf("day"); + const gte = now.subtract(days - 1, "day").startOf("day"); + + return { + gte: gte.toISOString(), + lt: lt.toISOString(), + }; +}; + +export const getYearToDateRange = (): DateRange => { + const now = dayjs.utc(); + const lt = now.add(1, "day").startOf("day"); + const gte = now.startOf("year"); + + return { + gte: gte.toISOString(), + lt: lt.toISOString(), + }; +}; + +export const getAllTimeRange = (): DateRange => { + const now = dayjs.utc(); + const lt = now.add(1, "day").startOf("day"); + const gte = now.subtract(10, "year").startOf("day"); + + return { + gte: gte.toISOString(), + lt: lt.toISOString(), + }; +}; + +export const PERIODS: readonly PeriodConfig[] = [ + { + value: "7d", + label: "7D", + getDateRange: () => getDateRangeForDays(7), + }, + { + value: "30d", + label: "30D", + getDateRange: () => getDateRangeForDays(30), + }, + { + value: "90d", + label: "90D", + getDateRange: () => getDateRangeForDays(90), + }, + { + value: "ytd", + label: "YTD", + getDateRange: getYearToDateRange, + }, + { + value: "all", + label: "All", + getDateRange: getAllTimeRange, + }, +] as const; + +export const CHART_PERIODS: readonly PeriodConfig<"7d" | "30d" | "90d">[] = + PERIODS.filter((period): period is PeriodConfig<"7d" | "30d" | "90d"> => + ["7d", "30d", "90d"].includes(period.value), + ); + +export type ChartPeriod = "7d" | "30d" | "90d"; + +export const getPeriodConfig = ( + value: Period, +): PeriodConfig | undefined => + PERIODS.find((period) => period.value === value); + +export const getDateRangeForPeriod = (value: Period): DateRange => { + const config = getPeriodConfig(value); + + return config?.getDateRange() ?? getDateRangeForDays(30); +}; + +export const getPeriodDays = (value: Period | string): number => { + const normalizedValue = value.toLowerCase(); + + switch (normalizedValue) { + case "7d": + return 7; + case "30d": + return 30; + case "90d": + return 90; + case "ytd": { + const now = dayjs.utc(); + const startOfYear = now.startOf("year"); + + return now.diff(startOfYear, "day") + 1; + } + case "all": + return 365 * 10; + default: + return 30; + } +}; + +export const DEFAULT_CHART_PERIOD: ChartPeriod = "30d"; +export const DEFAULT_PERIOD: Period = "30d"; diff --git a/admin/src/domains/inventory/products/utils/price-formatting.ts b/admin/src/domains/inventory/products/utils/price-formatting.ts new file mode 100644 index 000000000..90e166c89 --- /dev/null +++ b/admin/src/domains/inventory/products/utils/price-formatting.ts @@ -0,0 +1,85 @@ +import { useMemo } from "react"; +import type { ApiVariantPrice } from "@/graphql/types"; + +const NBSP = "\u00A0"; + +export const formatPrice = ( + amount: number, + currency: string = "RUB", + locale: string = "ru-RU", +): string => { + const formatted = new Intl.NumberFormat(locale, { + style: "currency", + currency, + currencyDisplay: "narrowSymbol", + }).format(amount / 100); + + return formatted.replace(/\s+/g, NBSP); +}; + +export const formatCurrencySymbol = ( + currency: string, + locale: string = "en-US", +): string => { + const currencyPart = new Intl.NumberFormat(locale, { + style: "currency", + currency, + currencyDisplay: "narrowSymbol", + }) + .formatToParts(0) + .find((part) => part.type === "currency"); + + return currencyPart?.value ?? currency; +}; + +export const formatShortDate = ( + date: Date, + locale: string = "ru-RU", +): string => + new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "short", + }).format(date); + +export const formatDateTime = ( + date: Date, + locale: string = "ru-RU", +): string => + new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); + +export const formatDateFull = ( + date: Date, + locale: string = "ru-RU", +): string => + new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "long", + year: "numeric", + }).format(date); + +export function useVariantPrice(price: ApiVariantPrice): string; +export function useVariantPrice( + price: ApiVariantPrice | null | undefined, + fallback?: string, +): string; +export function useVariantPrice( + price: ApiVariantPrice | null | undefined, + fallback = "\u2014", +): string { + const amountMinor = price?.amountMinor; + const currency = price?.currency; + + return useMemo(() => { + if (amountMinor === undefined || !currency) { + return fallback; + } + + return formatPrice(amountMinor, currency); + }, [amountMinor, currency, fallback]); +} diff --git a/admin/src/domains/inventory/products/utils/product-measurements.ts b/admin/src/domains/inventory/products/utils/product-measurements.ts new file mode 100644 index 000000000..dab07370b --- /dev/null +++ b/admin/src/domains/inventory/products/utils/product-measurements.ts @@ -0,0 +1,138 @@ +import { DimensionUnit, WeightUnit } from "@/graphql/types"; +import type { ApiVariantDimensions, ApiVariantWeight } from "@/graphql/types"; + +export interface VariantWeightFields { + weight: number | null; + weightUnit: WeightUnit; +} + +export interface VariantDimensionFields { + length: number | null; + width: number | null; + height: number | null; + dimensionUnit: DimensionUnit; +} + +export const DEFAULT_WEIGHT_UNIT = WeightUnit.G; +export const DEFAULT_DIMENSION_UNIT = DimensionUnit.Mm; + +export const WEIGHT_UNIT_LABELS: Record = { + [WeightUnit.G]: "g", + [WeightUnit.Kg]: "kg", + [WeightUnit.Lb]: "lb", + [WeightUnit.Oz]: "oz", +}; + +export const DIMENSION_UNIT_LABELS: Record = { + [DimensionUnit.Cm]: "cm", + [DimensionUnit.Ft]: "ft", + [DimensionUnit.In]: "in", + [DimensionUnit.M]: "m", + [DimensionUnit.Mm]: "mm", +}; + +export const getWeightUnitLabel = ( + unit: string | null | undefined, +): string => { + if (!unit) { + return WEIGHT_UNIT_LABELS[DEFAULT_WEIGHT_UNIT]; + } + + return WEIGHT_UNIT_LABELS[unit as WeightUnit] ?? unit; +}; + +export const getDimensionUnitLabel = ( + unit: string | null | undefined, +): string => { + if (!unit) { + return DIMENSION_UNIT_LABELS[DEFAULT_DIMENSION_UNIT]; + } + + return DIMENSION_UNIT_LABELS[unit as DimensionUnit] ?? unit; +}; + +const formatMeasurement = (value: number): string => + Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/\.?0+$/, ""); + +const convertWeightFromGrams = ( + weightGrams: number, + unit: WeightUnit, +): number => { + switch (unit) { + case WeightUnit.Kg: + return weightGrams / 1000; + case WeightUnit.Lb: + return weightGrams / 453.59237; + case WeightUnit.Oz: + return weightGrams / 28.349523125; + case WeightUnit.G: + default: + return weightGrams; + } +}; + +const convertDimensionFromMillimeters = ( + valueMm: number, + unit: DimensionUnit, +): number => { + switch (unit) { + case DimensionUnit.Cm: + return valueMm / 10; + case DimensionUnit.M: + return valueMm / 1000; + case DimensionUnit.In: + return valueMm / 25.4; + case DimensionUnit.Ft: + return valueMm / 304.8; + case DimensionUnit.Mm: + default: + return valueMm; + } +}; + +export const mapApiWeightToVariantFields = ( + weight: ApiVariantWeight | null | undefined, +): VariantWeightFields => ({ + weight: weight?.value ?? null, + weightUnit: DEFAULT_WEIGHT_UNIT, +}); + +export const mapApiDimensionsToVariantFields = ( + dimensions: ApiVariantDimensions | null | undefined, +): VariantDimensionFields => ({ + length: dimensions?.length ?? null, + width: dimensions?.width ?? null, + height: dimensions?.height ?? null, + dimensionUnit: DEFAULT_DIMENSION_UNIT, +}); + +export const formatApiWeight = ( + weight: ApiVariantWeight | null | undefined, +): string => { + if (!weight) { + return "-"; + } + + const value = convertWeightFromGrams(weight.value, DEFAULT_WEIGHT_UNIT); + + return `${formatMeasurement(value)} ${getWeightUnitLabel(DEFAULT_WEIGHT_UNIT)}`; +}; + +export const formatApiDimensions = ( + dimensions: ApiVariantDimensions | null | undefined, +): string => { + if (!dimensions) { + return "-"; + } + + const unit = DEFAULT_DIMENSION_UNIT; + const values = [ + convertDimensionFromMillimeters(dimensions.length, unit), + convertDimensionFromMillimeters(dimensions.width, unit), + convertDimensionFromMillimeters(dimensions.height, unit), + ].map(formatMeasurement); + + return `${values.join(" x ")} ${getDimensionUnitLabel(unit)}`; +}; diff --git a/admin/src/domains/inventory/products/utils/product-status.ts b/admin/src/domains/inventory/products/utils/product-status.ts new file mode 100644 index 000000000..e4afb4b97 --- /dev/null +++ b/admin/src/domains/inventory/products/utils/product-status.ts @@ -0,0 +1,53 @@ +import type { ApiProduct } from "@/graphql/types"; + +export type ProductStatus = "archived" | "draft" | "published"; + +export const PRODUCT_STATUS_LABELS: Record = { + archived: "Archived", + draft: "Draft", + published: "Published", +}; + +export const PRODUCT_STATUS_COLORS: Record = { + archived: "error", + draft: "default", + published: "success", +}; + +export const PRODUCT_STATUS_HINTS: Record = { + archived: "Product is archived", + draft: "Not visible on storefront", + published: null, +}; + +export const PRODUCT_STATUS_OPTIONS: Array<{ + label: string; + value: ProductStatus; +}> = [ + { label: PRODUCT_STATUS_LABELS.published, value: "published" }, + { label: PRODUCT_STATUS_LABELS.draft, value: "draft" }, + { label: PRODUCT_STATUS_LABELS.archived, value: "archived" }, +]; + +export const getProductStatus = (product: ApiProduct): ProductStatus => { + if (product.deletedAt) { + return "archived"; + } + + return product.isPublished ? "published" : "draft"; +}; + +export const getProductStatusLabel = (product: ApiProduct): string => + PRODUCT_STATUS_LABELS[getProductStatus(product)]; + +export const getProductStatusColor = (product: ApiProduct): string => + PRODUCT_STATUS_COLORS[getProductStatus(product)]; + +export const getProductStatusHint = (product: ApiProduct): string | null => + PRODUCT_STATUS_HINTS[getProductStatus(product)]; + +export const isProductPublished = (product: ApiProduct): boolean => + getProductStatus(product) === "published"; + +export const isProductArchived = (product: ApiProduct): boolean => + getProductStatus(product) === "archived"; diff --git a/admin/src/domains/inventory/tags/components/tag-details-card/index.ts b/admin/src/domains/inventory/tags/components/tag-details-card/index.ts new file mode 100644 index 000000000..679559cab --- /dev/null +++ b/admin/src/domains/inventory/tags/components/tag-details-card/index.ts @@ -0,0 +1,2 @@ +export { TagDetailsCard } from "./tag-details-card"; +export type { TagDetailsCardProps } from "./types"; diff --git a/admin/src/domains/inventory/tags/components/tag-details-card/tag-details-card.tsx b/admin/src/domains/inventory/tags/components/tag-details-card/tag-details-card.tsx new file mode 100644 index 000000000..47492abc1 --- /dev/null +++ b/admin/src/domains/inventory/tags/components/tag-details-card/tag-details-card.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Button, Dropdown, Flex, Tag, Typography } from "antd"; +import { LuEllipsis as MoreOutlined, LuTag as TagOutlined } from "react-icons/lu"; +import { CopyableChip } from "@/ui-kit/copyable-chip"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { formatDetailDate } from "@/domains/inventory/utils/format-detail-date"; +import { useTagEditIdentityModal } from "../../modals"; +import type { TagDetailsCardProps } from "./types"; + +export const TagDetailsCard = ({ tag, onRefetch }: TagDetailsCardProps) => { + const { push: openEditIdentityModal } = useTagEditIdentityModal(); + + return ( + + + + } + data-testid="tag-detail-status" + > + Tag + + + Created {formatDetailDate(tag.createdAt)} + + + } + actions={ + Edit identity, + onClick: () => + openEditIdentityModal({ + tag, + onSaved: onRefetch, + }), + }, + ], + }} + trigger={["click"]} + > + + } + > + + } + /> + +
    + {error && ( + + )} + +
    + + ref={gridRef} + theme={agGridTheme} + rowData={tags} + loading={loading} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={52} + onRowClicked={(event) => { + if (event.data) { + openTagModal({ entityId: event.data.id }); + } + }} + suppressCellFocus + suppressMovableColumns + onSortChanged={pageConfig.onSortChanged} + initialState={pageConfig.gridStateProps.initialState} + onStateUpdated={pageConfig.gridStateProps.onStateUpdated} + /> +
    + + +
    +
    + ); +} diff --git a/admin/src/domains/inventory/utils/format-detail-date.ts b/admin/src/domains/inventory/utils/format-detail-date.ts new file mode 100644 index 000000000..c5a92de0f --- /dev/null +++ b/admin/src/domains/inventory/utils/format-detail-date.ts @@ -0,0 +1,19 @@ +export const formatDetailDate = ( + value: Date | string | null | undefined, +): string => { + if (!value) { + return ""; + } + + const date = value instanceof Date ? value : new Date(value); + + if (Number.isNaN(date.getTime())) { + return String(value); + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +}; diff --git a/admin/src/domains/inventory/warehouse/components/index.ts b/admin/src/domains/inventory/warehouse/components/index.ts new file mode 100644 index 000000000..5df2d96d3 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/components/index.ts @@ -0,0 +1,2 @@ +export { WarehouseDefaultTag } from "./warehouse-default-tag"; +export { WarehouseNameCell } from "./warehouse-name-cell"; diff --git a/admin/src/domains/inventory/warehouse/components/warehouse-default-tag.tsx b/admin/src/domains/inventory/warehouse/components/warehouse-default-tag.tsx new file mode 100644 index 000000000..aff405448 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/components/warehouse-default-tag.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Tag } from "antd"; + +interface WarehouseDefaultTagProps { + isDefault: boolean; +} + +export function WarehouseDefaultTag({ isDefault }: WarehouseDefaultTagProps) { + if (!isDefault) { + return null; + } + + return ( + + Default + + ); +} diff --git a/admin/src/domains/inventory/warehouse/components/warehouse-details-card/index.ts b/admin/src/domains/inventory/warehouse/components/warehouse-details-card/index.ts new file mode 100644 index 000000000..019300ccd --- /dev/null +++ b/admin/src/domains/inventory/warehouse/components/warehouse-details-card/index.ts @@ -0,0 +1 @@ +export { WarehouseDetailsCard } from "./warehouse-details-card"; diff --git a/admin/src/domains/inventory/warehouse/components/warehouse-details-card/warehouse-details-card.tsx b/admin/src/domains/inventory/warehouse/components/warehouse-details-card/warehouse-details-card.tsx new file mode 100644 index 000000000..902b2724d --- /dev/null +++ b/admin/src/domains/inventory/warehouse/components/warehouse-details-card/warehouse-details-card.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Flex } from "antd"; +import type { ApiWarehouse } from "@/graphql/types"; +import { WarehouseInfoHeader } from "../warehouse-info-header"; + +interface WarehouseDetailsCardProps { + warehouse: ApiWarehouse; + onEditIdentity: () => void; + onEditDefault: () => void; + onDelete: () => void; +} + +export function WarehouseDetailsCard({ + warehouse, + onEditIdentity, + onEditDefault, + onDelete, +}: WarehouseDetailsCardProps) { + return ( + + + + ); +} diff --git a/admin/src/domains/inventory/warehouse/components/warehouse-info-header/index.ts b/admin/src/domains/inventory/warehouse/components/warehouse-info-header/index.ts new file mode 100644 index 000000000..598510e6d --- /dev/null +++ b/admin/src/domains/inventory/warehouse/components/warehouse-info-header/index.ts @@ -0,0 +1 @@ +export { WarehouseInfoHeader } from "./warehouse-info-header"; diff --git a/admin/src/domains/inventory/warehouse/components/warehouse-info-header/warehouse-info-header.styles.ts b/admin/src/domains/inventory/warehouse/components/warehouse-info-header/warehouse-info-header.styles.ts new file mode 100644 index 000000000..fcd9f2d3c --- /dev/null +++ b/admin/src/domains/inventory/warehouse/components/warehouse-info-header/warehouse-info-header.styles.ts @@ -0,0 +1,22 @@ +import { createStyles } from "antd-style"; + +export const useWarehouseInfoHeaderStyles = createStyles(({ token }) => ({ + statusTag: { + margin: 0, + display: "inline-flex", + alignItems: "center", + gap: 4, + fontWeight: 500, + }, + metaText: { + fontSize: token.fontSizeSM, + }, + warehouseTitle: {}, + divider: { + marginBlock: token.margin, + }, + kpiTile: { + padding: "12px 16px", + background: token.colorBgElevated, + }, +})); diff --git a/admin/src/domains/inventory/warehouse/components/warehouse-info-header/warehouse-info-header.tsx b/admin/src/domains/inventory/warehouse/components/warehouse-info-header/warehouse-info-header.tsx new file mode 100644 index 000000000..0975ee2e9 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/components/warehouse-info-header/warehouse-info-header.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } from "react"; +import { Button, Divider, Dropdown, Flex, Popover, Tag, Typography } from "antd"; +import type { MenuProps } from "antd"; +import { LuCircleCheck as CheckCircleOutlined, LuTrash2 as DeleteOutlined, LuPencil as EditOutlined, LuEllipsis as MoreOutlined } from "react-icons/lu"; +import type { ApiWarehouse } from "@/graphql/types"; +import { CopyableChip } from "@/ui-kit/copyable-chip"; +import { KPITile } from "@/ui-kit/kpi-tile"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { formatDetailDate } from "@/domains/inventory/utils/format-detail-date"; +import { PeriodSwitch } from "@/domains/inventory/products/components/period-switch"; +import { UserPopoverContent } from "@/domains/inventory/products/components/product-info-header/components"; +import { PERIODS, type Period } from "@/domains/inventory/products/utils/periods"; +import { WarehouseDefaultTag } from "../warehouse-default-tag"; +import { useWarehouseInfoHeaderStyles } from "./warehouse-info-header.styles"; + +interface WarehouseInfoHeaderProps { + warehouse: ApiWarehouse; + onEditIdentity: () => void; + onEditDefault: () => void; + onDelete: () => void; +} + +export function WarehouseInfoHeader({ + warehouse, + onEditIdentity, + onEditDefault, + onDelete, +}: WarehouseInfoHeaderProps) { + const { styles } = useWarehouseInfoHeaderStyles(); + const [kpiPeriod, setKpiPeriod] = useState("7d"); + const [compareEnabled, setCompareEnabled] = useState(false); + const actionItems: MenuProps["items"] = [ + { + key: "edit-identity", + label: "Edit identity", + icon: , + onClick: onEditIdentity, + }, + { + key: "edit-default", + label: warehouse.isDefault ? "Default settings" : "Set as default", + icon: , + onClick: onEditDefault, + }, + { + type: "divider", + }, + { + key: "delete", + label: "Delete warehouse", + icon: , + danger: true, + onClick: onDelete, + }, + ]; + + const statusTitle = ( + + {!warehouse.isDefault && ( + Warehouse + )} + + + Updated {formatDetailDate(warehouse.updatedAt)} + by + + } + placement="bottom" + arrow={false} + > + + + + + ); + + const topBarActions = ( + + + + ), + [canSubmit, handleDelete, loading], + ); + + return ( + + } + > + + +
    + Code + {warehouse.code} +
    +
    + + Stocked variants + + {warehouse.variantsCount} +
    + + This removes the warehouse and associated warehouse stock records if + the API allows deletion. + + + {requiresConfirmation && ( + <> + + Type {warehouse.code} to confirm + + setConfirmation(event.target.value)} + status={!canSubmit && confirmation ? "error" : undefined} + data-testid="delete-warehouse-confirmation-input" + style={{ marginTop: 8 }} + /> + + )} + + {apiError && ( + {apiError} + )} +
    +
    + ); +} diff --git a/admin/src/domains/inventory/warehouse/modals/delete-warehouse-modal/index.ts b/admin/src/domains/inventory/warehouse/modals/delete-warehouse-modal/index.ts new file mode 100644 index 000000000..4ec8a4916 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/delete-warehouse-modal/index.ts @@ -0,0 +1 @@ +export { DeleteWarehouseModal } from "./delete-warehouse-modal"; diff --git a/admin/src/domains/inventory/warehouse/modals/edit-default-modal/edit-default-modal.tsx b/admin/src/domains/inventory/warehouse/modals/edit-default-modal/edit-default-modal.tsx new file mode 100644 index 000000000..8e4366ef0 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/edit-default-modal/edit-default-modal.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { App, Alert, Checkbox, Typography } from "antd"; +import { createStyles } from "antd-style"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useUpdateWarehouse } from "../../hooks"; +import type { IWarehouseEditDefaultModalPayload } from "../index"; + +const useStyles = createStyles(({ token }) => ({ + help: { + display: "block", + marginTop: 8, + color: token.colorTextSecondary, + fontSize: 12, + }, +})); + +export function EditDefaultModal() { + const { styles } = useStyles(); + const { message } = App.useApp(); + const { payload, pop } = useModalStackContext(); + const typedPayload = payload as IWarehouseEditDefaultModalPayload; + const [checked, setChecked] = useState(true); + const { updateWarehouse, loading } = useUpdateWarehouse(); + const isAlreadyDefault = typedPayload.warehouse.isDefault; + + const handleSubmit = useCallback(async () => { + if (isAlreadyDefault || !checked) { + return; + } + + const { warehouse, userErrors } = await updateWarehouse( + { + id: typedPayload.warehouse.id, + isDefault: true, + }, + { + listQueryVariables: typedPayload.listQueryVariables, + detailsQueryVariables: typedPayload.detailsQueryVariables, + onCompleted: typedPayload.onUpdated, + }, + ); + + if (userErrors.length > 0) { + message.error(userErrors[0].message); + return; + } + + if (warehouse) { + message.success("Default warehouse updated"); + pop(); + } + }, [ + checked, + isAlreadyDefault, + message, + pop, + typedPayload, + updateWarehouse, + ]); + + return ( + + } + > + + + + setChecked(event.target.checked)} + data-testid="edit-warehouse-default-checkbox" + > + Make "{typedPayload.warehouse.name}" the default warehouse + + + Current default will be replaced after saving. + + + + ); +} diff --git a/admin/src/domains/inventory/warehouse/modals/edit-default-modal/index.ts b/admin/src/domains/inventory/warehouse/modals/edit-default-modal/index.ts new file mode 100644 index 000000000..6e55f6259 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/edit-default-modal/index.ts @@ -0,0 +1 @@ +export { EditDefaultModal } from "./edit-default-modal"; diff --git a/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/edit-identity-modal.tsx b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/edit-identity-modal.tsx new file mode 100644 index 000000000..98da5660d --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/edit-identity-modal.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { App, Input, Typography } from "antd"; +import { createStyles } from "antd-style"; +import { + ModalHeader, + ModalLayout, + useModalStackContext, +} from "@/layouts/modals"; +import { Paper, PaperHeader } from "@/ui-kit/paper"; +import { useUpdateWarehouse } from "../../hooks"; +import { + mapUpdateWarehouseIdentityFormToInput, + mapWarehouseUserErrorsToFormErrors, + normalizeWarehouseCode, +} from "../../mappers"; +import type { IWarehouseEditIdentityModalPayload } from "../index"; +import { editWarehouseIdentitySchema } from "./schema"; +import type { EditWarehouseIdentityFormValues } from "./types"; + +const useStyles = createStyles(({ token }) => ({ + fieldGroup: { + display: "flex", + gap: 16, + marginBottom: 12, + "@media (max-width: 720px)": { + flexDirection: "column", + }, + }, + field: { + flex: 1, + minWidth: 0, + }, + label: { + display: "block", + marginBottom: 4, + fontSize: 13, + fontWeight: 500, + }, + error: { + display: "block", + marginTop: 4, + color: token.colorError, + fontSize: 12, + }, + help: { + display: "block", + marginTop: 6, + color: token.colorTextSecondary, + fontSize: 12, + }, +})); + +export function EditIdentityModal() { + const { styles } = useStyles(); + const { message } = App.useApp(); + const { payload, pop } = useModalStackContext(); + const typedPayload = payload as IWarehouseEditIdentityModalPayload; + const { updateWarehouse, loading } = useUpdateWarehouse(); + + const { + control, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(editWarehouseIdentitySchema), + defaultValues: { + name: typedPayload.warehouse.name, + code: typedPayload.warehouse.code, + }, + }); + + const onSubmit = useCallback( + async (values: EditWarehouseIdentityFormValues) => { + const { warehouse, userErrors } = await updateWarehouse( + mapUpdateWarehouseIdentityFormToInput({ + id: typedPayload.warehouse.id, + ...values, + }), + { + listQueryVariables: typedPayload.listQueryVariables, + detailsQueryVariables: typedPayload.detailsQueryVariables, + onCompleted: typedPayload.onUpdated, + }, + ); + + if (userErrors.length > 0) { + mapWarehouseUserErrorsToFormErrors(userErrors).forEach((error) => { + if (error.field !== "name" && error.field !== "code") { + return; + } + + setError(error.field, { message: error.message }); + }); + + message.error(userErrors[0].message); + return; + } + + if (warehouse) { + message.success("Warehouse identity updated"); + pop(); + } + }, + [ + message, + pop, + setError, + typedPayload, + updateWarehouse, + ], + ); + + return ( + + } + > + + +
    +
    + Name + ( + + )} + /> + {errors.name && ( + + {errors.name.message} + + )} +
    + +
    + Code + ( + + field.onChange(normalizeWarehouseCode(event.target.value)) + } + /> + )} + /> + {errors.code ? ( + + {errors.code.message} + + ) : ( + + Changing the code can affect imports, reports, and integrations. + + )} +
    +
    +
    +
    + ); +} diff --git a/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/index.ts b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/index.ts new file mode 100644 index 000000000..d099d9fdc --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/index.ts @@ -0,0 +1 @@ +export { EditIdentityModal } from "./edit-identity-modal"; diff --git a/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/schema.ts b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/schema.ts new file mode 100644 index 000000000..d01a619f8 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const editWarehouseIdentitySchema = z.object({ + name: z + .string() + .trim() + .min(2, "Name must be at least 2 characters") + .max(255, "Name must be 255 characters or less"), + code: z + .string() + .trim() + .min(1, "Code is required") + .max(32, "Code must be 32 characters or less") + .regex( + /^[A-Z0-9]+(?:-[A-Z0-9]+)*$/, + "Use uppercase letters, numbers, and single hyphens", + ), +}); diff --git a/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/types.ts b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/types.ts new file mode 100644 index 000000000..d63a1b73c --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/edit-identity-modal/types.ts @@ -0,0 +1,4 @@ +export interface EditWarehouseIdentityFormValues { + name: string; + code: string; +} diff --git a/admin/src/domains/inventory/warehouse/modals/index.ts b/admin/src/domains/inventory/warehouse/modals/index.ts new file mode 100644 index 000000000..254c48320 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/index.ts @@ -0,0 +1,67 @@ +import { createModalStackHook } from "@/layouts/modals"; +import type { IModalStackPayload } from "@/layouts/modals/types"; +import type { ApiWarehouse } from "@/graphql/types"; +import type { + WarehouseDetailsQueryVariables, + WarehousesQueryVariables, +} from "../graphql"; + +export const WAREHOUSE_MODAL_TYPE = "warehouse"; +export const WAREHOUSE_CREATE_MODAL_TYPE = "warehouse-create"; +export const WAREHOUSE_EDIT_IDENTITY_MODAL_TYPE = "warehouse-edit-identity"; +export const WAREHOUSE_EDIT_DEFAULT_MODAL_TYPE = "warehouse-edit-default"; +export const WAREHOUSE_DELETE_MODAL_TYPE = "warehouse-delete"; + +export interface IWarehouseModalPayload extends IModalStackPayload { + entityId: string; + listQueryVariables?: WarehousesQueryVariables; +} + +export interface IWarehouseCreateModalPayload extends IModalStackPayload { + listQueryVariables?: WarehousesQueryVariables; +} + +export interface IWarehouseEditIdentityModalPayload + extends IModalStackPayload { + warehouse: ApiWarehouse; + listQueryVariables?: WarehousesQueryVariables; + detailsQueryVariables?: WarehouseDetailsQueryVariables; + onUpdated?: (warehouse: ApiWarehouse) => void | Promise; +} + +export interface IWarehouseEditDefaultModalPayload extends IModalStackPayload { + warehouse: ApiWarehouse; + listQueryVariables?: WarehousesQueryVariables; + detailsQueryVariables?: WarehouseDetailsQueryVariables; + onUpdated?: (warehouse: ApiWarehouse) => void | Promise; +} + +export interface IWarehouseDeleteModalPayload extends IModalStackPayload { + warehouse: ApiWarehouse; + listQueryVariables?: WarehousesQueryVariables; + onDeleted?: (deletedWarehouseId: string) => void | Promise; +} + +declare module "@/layouts/modals" { + interface ModalStackPayloads { + [WAREHOUSE_MODAL_TYPE]: IWarehouseModalPayload; + [WAREHOUSE_CREATE_MODAL_TYPE]: IWarehouseCreateModalPayload; + [WAREHOUSE_EDIT_IDENTITY_MODAL_TYPE]: IWarehouseEditIdentityModalPayload; + [WAREHOUSE_EDIT_DEFAULT_MODAL_TYPE]: IWarehouseEditDefaultModalPayload; + [WAREHOUSE_DELETE_MODAL_TYPE]: IWarehouseDeleteModalPayload; + } +} + +export const useWarehouseModal = createModalStackHook(WAREHOUSE_MODAL_TYPE); +export const useWarehouseCreateModal = createModalStackHook( + WAREHOUSE_CREATE_MODAL_TYPE, +); +export const useWarehouseEditIdentityModal = createModalStackHook( + WAREHOUSE_EDIT_IDENTITY_MODAL_TYPE, +); +export const useWarehouseEditDefaultModal = createModalStackHook( + WAREHOUSE_EDIT_DEFAULT_MODAL_TYPE, +); +export const useWarehouseDeleteModal = createModalStackHook( + WAREHOUSE_DELETE_MODAL_TYPE, +); diff --git a/admin/src/domains/inventory/warehouse/modals/warehouse-modal/index.ts b/admin/src/domains/inventory/warehouse/modals/warehouse-modal/index.ts new file mode 100644 index 000000000..d112659cd --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/warehouse-modal/index.ts @@ -0,0 +1 @@ +export { WarehouseModal } from "./warehouse-modal"; diff --git a/admin/src/domains/inventory/warehouse/modals/warehouse-modal/warehouse-modal.tsx b/admin/src/domains/inventory/warehouse/modals/warehouse-modal/warehouse-modal.tsx new file mode 100644 index 000000000..95282c2e8 --- /dev/null +++ b/admin/src/domains/inventory/warehouse/modals/warehouse-modal/warehouse-modal.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useCallback, useEffect, useMemo } from "react"; +import { Alert, Flex, Skeleton } from "antd"; +import { ModalLayout, useModalStackContext } from "@/layouts/modals"; +import { WarehouseDetailsCard } from "../../components/warehouse-details-card"; +import { useWarehouse } from "../../hooks"; +import { + useWarehouseDeleteModal, + useWarehouseEditDefaultModal, + useWarehouseEditIdentityModal, + type IWarehouseModalPayload, +} from "../index"; + +export function WarehouseModal() { + const { payload, pop, forcePop } = useModalStackContext(); + const typedPayload = payload as IWarehouseModalPayload; + const { push: openEditIdentityModal } = useWarehouseEditIdentityModal(); + const { push: openEditDefaultModal } = useWarehouseEditDefaultModal(); + const { push: openDeleteModal } = useWarehouseDeleteModal(); + const entityId = + typedPayload.entityId === undefined || typedPayload.entityId === null + ? null + : String(typedPayload.entityId); + const detailsQueryVariables = useMemo( + () => + entityId + ? { + id: entityId, + } + : undefined, + [entityId], + ); + + const { warehouse, loading, error, refetch } = useWarehouse({ + id: entityId, + }); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + pop(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [pop]); + + const handleEditIdentity = useCallback(() => { + if (!warehouse) { + return; + } + + openEditIdentityModal({ + warehouse, + listQueryVariables: typedPayload.listQueryVariables, + detailsQueryVariables, + onUpdated: async () => { + await refetch(); + }, + }); + }, [ + detailsQueryVariables, + openEditIdentityModal, + refetch, + typedPayload.listQueryVariables, + warehouse, + ]); + + const handleEditDefault = useCallback(() => { + if (!warehouse) { + return; + } + + openEditDefaultModal({ + warehouse, + listQueryVariables: typedPayload.listQueryVariables, + detailsQueryVariables, + onUpdated: async () => { + await refetch(); + }, + }); + }, [ + detailsQueryVariables, + openEditDefaultModal, + refetch, + typedPayload.listQueryVariables, + warehouse, + ]); + + const handleDelete = useCallback(() => { + if (!warehouse) { + return; + } + + openDeleteModal({ + warehouse, + listQueryVariables: typedPayload.listQueryVariables, + onDeleted: () => { + forcePop(); + }, + }); + }, [ + forcePop, + openDeleteModal, + typedPayload.listQueryVariables, + warehouse, + ]); + + const renderContent = () => { + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!warehouse) { + return ( + + + + ); + } + + return ( + + ); + }; + + return ( + + {renderContent()} + + ); +} diff --git a/admin/src/domains/inventory/warehouse/page/page.tsx b/admin/src/domains/inventory/warehouse/page/page.tsx new file mode 100644 index 000000000..047b5699b --- /dev/null +++ b/admin/src/domains/inventory/warehouse/page/page.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { Alert, Button, Flex, Input, Select, Typography } from "antd"; +import { LuPlus as PlusOutlined } from "react-icons/lu"; +import { createStyles } from "antd-style"; +import { AgGridReact } from "ag-grid-react"; +import type { CustomCellRendererProps } from "ag-grid-react"; +import { + AllCommunityModule, + ColDef, + GridStateModule, + ModuleRegistry, + RowClickedEvent, + RowSelectionModule, +} from "ag-grid-community"; +import { DataLayout } from "@/layouts/data"; +import { + RelayCursorPagination, + useRelayCursorPagination, +} from "@/ui-kit/cursor-pagination"; +import { useAgGridTheme, useGridSort, useGridState } from "@/hooks"; +import type { SortModel } from "@/hooks/use-grid-sort"; +import type { + ApiWarehouse, + ApiWarehouseOrderByInput, + ApiWarehouseWhereInput, +} from "@/graphql/types"; +import { + SortDirection, + WarehouseOrderField, +} from "@/graphql/types"; +import { WarehouseNameCell } from "../components"; +import { useWarehouses } from "../hooks"; +import { + useWarehouseCreateModal, + useWarehouseModal, +} from "../modals"; +import type { WarehousesQueryVariables } from "../graphql"; + +ModuleRegistry.registerModules([ + AllCommunityModule, + RowSelectionModule, + GridStateModule, +]); + +type DefaultFilterValue = "all" | "default" | "non-default"; + +const WAREHOUSE_SORT_FIELDS: Partial> = { + name: WarehouseOrderField.Name, + code: WarehouseOrderField.Code, + isDefault: WarehouseOrderField.IsDefault, + updatedAt: WarehouseOrderField.UpdatedAt, + createdAt: WarehouseOrderField.CreatedAt, +}; + +const useStyles = createStyles(({ token }) => ({ + gridContainer: { + height: "100%", + paddingBottom: token.padding, + display: "flex", + flexDirection: "column", + gap: token.paddingSM, + }, + gridWrapper: { + flex: 1, + minHeight: 0, + }, + monospace: { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + }, + toolbarInput: { + width: 320, + }, + toolbarSelect: { + width: 160, + }, +})); + +function formatDate(value: string): string { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(value)); +} + +function mapSortModelToOrderBy( + sortModel: SortModel[], +): ApiWarehouseOrderByInput[] | null { + const orderBy = sortModel + .map((sort) => { + const field = WAREHOUSE_SORT_FIELDS[sort.colId]; + + if (!field || !sort.sort) { + return null; + } + + return { + field, + direction: + sort.sort === "desc" ? SortDirection.Desc : SortDirection.Asc, + }; + }) + .filter((item): item is ApiWarehouseOrderByInput => item !== null); + + return orderBy.length > 0 ? orderBy : null; +} + +const WarehouseCellRenderer = ( + props: CustomCellRendererProps, +) => { + if (!props.data) { + return null; + } + + return ; +}; + +const CodeCellRenderer = ( + props: CustomCellRendererProps, +) => ( + + {props.value ?? ""} + +); + +const DateCellRenderer = ( + props: CustomCellRendererProps, +) => {props.value ? formatDate(props.value) : ""}; + +export default function WarehousePage() { + const { styles } = useStyles(); + const agGridTheme = useAgGridTheme(); + const gridRef = useRef>(null); + const [searchValue, setSearchValue] = useState(""); + const [defaultFilter, setDefaultFilter] = + useState("all"); + const [sortModel, setSortModel] = useState([]); + const { push: openCreateModal } = useWarehouseCreateModal(); + const { push: openWarehouseModal } = useWarehouseModal(); + const { initialState, onStateUpdated } = useGridState({ + storageKey: "warehouse-grid-state", + }); + + const where = useMemo(() => { + const filters: ApiWarehouseWhereInput[] = []; + const query = searchValue.trim(); + + if (query) { + filters.push({ + _or: [ + { name: { _containsi: query } }, + { code: { _containsi: query } }, + ], + }); + } + + if (defaultFilter === "default") { + filters.push({ isDefault: { _eq: true } }); + } + + if (defaultFilter === "non-default") { + filters.push({ isDefault: { _eq: false } }); + } + + return filters.length > 0 ? { _and: filters } : null; + }, [defaultFilter, searchValue]); + + const orderBy = useMemo( + () => mapSortModelToOrderBy(sortModel), + [sortModel], + ); + const resetKey = useMemo( + () => JSON.stringify({ where, orderBy }), + [orderBy, where], + ); + const pagination = useRelayCursorPagination({ + defaultPageSize: 20, + resetKey, + }); + + const listQueryVariables = useMemo( + () => ({ + ...pagination.variables, + where, + orderBy, + }), + [orderBy, pagination.variables, where], + ); + + const { + warehouses, + totalCount, + pageInfo, + loading, + error, + } = useWarehouses(listQueryVariables); + + const handleSortChange = useCallback((model: SortModel[]) => { + setSortModel(model); + }, []); + + const { onSortChanged } = useGridSort({ + gridRef, + sortModel, + onSortChange: handleSortChange, + }); + + const handleCreate = useCallback(() => { + openCreateModal({ listQueryVariables }); + }, [listQueryVariables, openCreateModal]); + + const handleRowClicked = useCallback( + (event: RowClickedEvent) => { + if (!event.data) { + return; + } + + openWarehouseModal({ + entityId: event.data.id, + listQueryVariables, + }); + }, + [listQueryVariables, openWarehouseModal], + ); + + const columnDefs = useMemo[]>( + () => [ + { + headerName: "Warehouse", + field: "name", + cellRenderer: WarehouseCellRenderer, + flex: 2, + minWidth: 260, + }, + { + headerName: "Code", + field: "code", + cellRenderer: CodeCellRenderer, + flex: 1, + minWidth: 150, + }, + { + headerName: "Stocked variants", + field: "variantsCount", + minWidth: 150, + sortable: false, + type: "rightAligned", + }, + { + headerName: "Updated", + field: "updatedAt", + cellRenderer: DateCellRenderer, + minWidth: 140, + }, + { + headerName: "Created", + field: "createdAt", + cellRenderer: DateCellRenderer, + minWidth: 140, + }, + ], + [], + ); + + const defaultColDef = useMemo( + () => ({ + resizable: true, + sortable: true, + comparator: () => 0, + cellStyle: { display: "flex", alignItems: "center" }, + }), + [], + ); + + return ( + } + onClick={handleCreate} + > + New + + } + > + + setSearchValue(event.target.value)} + data-testid="warehouse-search-input" + /> + + className={styles.toolbarSelect} + value={defaultFilter} + onChange={setDefaultFilter} + options={[ + { value: "all", label: "Default: All" }, + { value: "default", label: "Default only" }, + { value: "non-default", label: "Non-default" }, + ]} + data-testid="warehouse-default-filter" + /> +
    + } + /> + +
    + {error && } +
    + + ref={gridRef} + theme={agGridTheme} + context={{ styles }} + rowData={warehouses} + loading={loading} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + getRowId={(params) => params.data.id} + rowHeight={56} + suppressCellFocus + suppressMovableColumns + rowStyle={{ cursor: "pointer" }} + onRowClicked={handleRowClicked} + onSortChanged={onSortChanged} + initialState={initialState} + onStateUpdated={onStateUpdated} + /> +
    + + +
    + + ); +} diff --git a/admin/src/domains/media/components/entity-media-gallery/entity-media-gallery.tsx b/admin/src/domains/media/components/entity-media-gallery/entity-media-gallery.tsx index 53ce51f5c..ef8e7332d 100644 --- a/admin/src/domains/media/components/entity-media-gallery/entity-media-gallery.tsx +++ b/admin/src/domains/media/components/entity-media-gallery/entity-media-gallery.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, CSSProperties } from "react"; +import { useCallback, useMemo, useState, CSSProperties, ReactNode } from "react"; import { Button, Typography, @@ -9,20 +9,9 @@ import { Flex, Empty, Space, + type MenuProps, } from "antd"; -import { - PlusOutlined, - DeleteOutlined, - StarOutlined, - StarFilled, - MoreOutlined, - EyeOutlined, - UploadOutlined, - AppstoreOutlined, - UnorderedListOutlined, - HolderOutlined, - FolderOpenOutlined, -} from "@ant-design/icons"; +import { LuPlus as PlusOutlined, LuTrash2 as DeleteOutlined, LuStar as StarOutlined, LuStar as StarFilled, LuEllipsis as MoreOutlined, LuEye as EyeOutlined, LuUpload as UploadOutlined, LuLayoutGrid as AppstoreOutlined, LuList as UnorderedListOutlined, LuGripVertical as HolderOutlined, LuFolderOpen as FolderOpenOutlined, LuPencil as EditOutlined } from "react-icons/lu"; import { FeaturedBadge } from "@/ui-kit/featured-badge"; import { DndContext, @@ -68,6 +57,23 @@ const getFileName = (file: ApiFile): string => file.originalName || file.id; const getFileSize = (file: ApiFile): number => Number(file.sizeBytes) || 0; const getFileExt = (file: ApiFile): string => file.ext?.toUpperCase() || ""; +const MediaThumbnail = ({ item, className }: { item: ApiFile; className: string }) => { + const isVideo = item.mimeType?.startsWith("video/") ?? false; + + return isVideo ? ( +