diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 0000000000..8d776b76fa --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,60 @@ +Review PR #$ARGUMENTS using the comprehensive review guidelines. + +## Steps + +1. Read the review prompt from `docs/REVIEW_PROMPT.md` for all criteria +2. Fetch PR details: `gh pr view $ARGUMENTS --json title,body,files,additions,deletions,author` +3. Fetch PR diff: `gh pr diff $ARGUMENTS` +4. If files changed > 15, flag for potential split before deep review + +## Review Criteria + +Apply all sections from the review prompt: + +1. **Critical**: Security, error handling, rendering strategy, correctness, breaking changes +2. **Standard**: TypeScript, React patterns, localization/RTL, performance, accessibility +3. **Clean Code**: DRY, KISS, single responsibility, separation of concerns, SOLID +4. **Polish**: Function size, comments, dead code, console.log, naming +5. **Watch Out For**: Common bug patterns +6. **Bugs & Regressions**: + - Existing functionality still works + - Side effects on shared code/styles/state + - Runtime issues (race conditions, memory leaks) + - Testing verification based on what the PR changes (variants, themes, RTL, responsive, states, + user flows) + +## Issue Categories + +Use these labels when categorizing issues: + +| Category | Covers | +| --------------- | ------------------------------------------------ | +| `[Security]` | Secrets, XSS, input validation | +| `[Correctness]` | Logic bugs, wrong behavior, missing requirements | +| `[Regression]` | Breaking existing functionality | +| `[Performance]` | Re-renders, bundle size, memoization | +| `[Theming]` | Colors, dark/light/sepia mode | +| `[RTL]` | Logical properties, layout direction | +| `[i18n]` | Hardcoded strings, localization | +| `[A11y]` | Accessibility issues | +| `[Testing]` | Missing tests, untested scenarios | +| `[TypeScript]` | Types, any usage, interfaces | +| `[React]` | Patterns, hooks, state management | +| `[Clean Code]` | DRY, KISS, SOLID violations | +| `[Polish]` | Naming, formatting, comments, EOF | + +## Output Format + +Use the format from `docs/REVIEW_PROMPT.md`: + +- **Summary** (brief, note if part of PR chain) +- **🔴 Critical Issues** - Use `[Category]` in title (e.g., + `#### 1. [Regression] Theme Settings Removed`) +- **🟠 Medium Issues** - Use `[Category]` in title +- **🟡 Low Issues** - Use `[Category]` in title +- **✅ What's Done Well** +- **🎯 Verdict** (conversational conclusion) + +## Final Step + +Ask me whether to post the review or make adjustments before posting to GitHub. diff --git a/.cursor/prompts/review.prompt.md b/.cursor/prompts/review.prompt.md new file mode 100644 index 0000000000..c7d3540389 --- /dev/null +++ b/.cursor/prompts/review.prompt.md @@ -0,0 +1,32 @@ +Review my code changes against the Quran.com frontend project guidelines. + +## Context + +- Next.js (Pages Router), TypeScript strict mode, SCSS modules +- next-translate for i18n, useSWR for data fetching, Radix UI components + +## Check For + +**TypeScript**: No `any` types, explicit return types on exports, interfaces for objects, enums for repeated values + +**React**: Functional components only, Props interface, proper memoization, no unnecessary useEffect, skeleton loaders for async data + +**Code Quality**: Functions <30 lines, DRY code, proper error handling with fallbacks, no unused code, comments explain "why" + +**API/Data**: useSWR for fetching, error states handled, API response fallbacks, optimistic updates + +**Localization**: All text uses `t('key')` from next-translate, no hardcoded strings, RTL-safe CSS (logical properties like margin-inline-start) + +**Accessibility**: Semantic HTML, ARIA attributes, keyboard navigation + +**Security**: No hardcoded secrets, env vars for config, input validation + +**Testing**: Tests for new functionality, edge cases, error scenarios + +## Output Format + +1. **🚨 Critical** - Must fix before PR +2. **💡 Suggestions** - Improvements to consider +3. **✅ Good** - What's done well + +Include file paths and line numbers. diff --git a/.cursor/rules/bug-handling-with-todo-comments.mdc b/.cursor/rules/bug-handling-with-todo-comments.mdc new file mode 100644 index 0000000000..f206db127c --- /dev/null +++ b/.cursor/rules/bug-handling-with-todo-comments.mdc @@ -0,0 +1,5 @@ +--- +description: Specifies the usage of TODO comments to outline problems or bugs encountered in existing code, regardless of file type. +globs: **/*.* +--- +- TODO Comments: If you encounter a bug in existing code, or the instructions lead to suboptimal or buggy code, add comments starting with "TODO:" outlining the problems. \ No newline at end of file diff --git a/.cursor/rules/clean-code.mdc b/.cursor/rules/clean-code.mdc new file mode 100644 index 0000000000..eb4f5bc361 --- /dev/null +++ b/.cursor/rules/clean-code.mdc @@ -0,0 +1,56 @@ +--- +description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality. +globs: +alwaysApply: false +--- +# Clean Code Guidelines + +## Constants Over Magic Numbers +- Replace hard-coded values with named constants +- Use descriptive constant names that explain the value's purpose +- Keep constants at the top of the file or in a dedicated constants file + +## Meaningful Names +- Variables, functions, and classes should reveal their purpose +- Names should explain why something exists and how it's used +- Avoid abbreviations unless they're universally understood + +## Smart Comments +- Don't comment on what the code does - make the code self-documenting +- Use comments to explain why something is done a certain way +- Document APIs, complex algorithms, and non-obvious side effects + +## Single Responsibility +- Each function should do exactly one thing +- Functions should be small and focused +- If a function needs a comment to explain what it does, it should be split + +## DRY (Don't Repeat Yourself) +- Extract repeated code into reusable functions +- Share common logic through proper abstraction +- Maintain single sources of truth + +## Clean Structure +- Keep related code together +- Organize code in a logical hierarchy +- Use consistent file and folder naming conventions + +## Encapsulation +- Hide implementation details +- Expose clear interfaces +- Move nested conditionals into well-named functions + +## Code Quality Maintenance +- Refactor continuously +- Fix technical debt early +- Leave code cleaner than you found it + +## Testing +- Write tests before fixing bugs +- Keep tests readable and maintainable +- Test edge cases and error conditions + +## Version Control +- Write clear commit messages +- Make small, focused commits +- Use meaningful branch names \ No newline at end of file diff --git a/.cursor/rules/codequality.mdc b/.cursor/rules/codequality.mdc new file mode 100644 index 0000000000..738394c7ed --- /dev/null +++ b/.cursor/rules/codequality.mdc @@ -0,0 +1,45 @@ +--- +description: Code Quality Guidelines +globs: +alwaysApply: false +--- +# Code Quality Guidelines + +## Verify Information +Always verify information before presenting it. Do not make assumptions or speculate without clear evidence. + +## File-by-File Changes +Make changes file by file and give me a chance to spot mistakes. + +## No Apologies +Never use apologies. + +## No Understanding Feedback +Avoid giving feedback about understanding in comments or documentation. + +## No Whitespace Suggestions +Don't suggest whitespace changes. + +## No Inventions +Don't invent changes other than what's explicitly requested. + +## No Unnecessary Confirmations +Don't ask for confirmation of information already provided in the context. + +## Preserve Existing Code +Don't remove unrelated code or functionalities. Pay attention to preserving existing structures. + +## Single Chunk Edits +Provide all edits in a single chunk instead of multiple-step instructions or explanations for the same file. + +## No Implementation Checks +Don't ask the user to verify implementations that are visible in the provided context. + +## No Unnecessary Updates +Don't suggest updates or changes to files when there are no actual modifications needed. + +## Provide Real File Links +Always provide links to the real files, not x.md. + +## No Current Implementation +Don't show or discuss the current implementation unless specifically requested. diff --git a/.cursor/rules/coding-guidelines---early-returns-and-conditionals.mdc b/.cursor/rules/coding-guidelines---early-returns-and-conditionals.mdc new file mode 100644 index 0000000000..12850e8cc2 --- /dev/null +++ b/.cursor/rules/coding-guidelines---early-returns-and-conditionals.mdc @@ -0,0 +1,6 @@ +--- +description: Applies coding guidelines related to using early returns and conditional classes in all files. +globs: **/*.* +--- +- Utilize Early Returns: Use early returns to avoid nested conditions and improve readability. +- Conditional Classes: Prefer conditional classes over ternary operators for class attributes. \ No newline at end of file diff --git a/.cursor/rules/comment-usage.mdc b/.cursor/rules/comment-usage.mdc new file mode 100644 index 0000000000..3bff77b1e4 --- /dev/null +++ b/.cursor/rules/comment-usage.mdc @@ -0,0 +1,8 @@ +--- +description: This rule dictates how comments should be used within the codebase to enhance understanding and avoid clutter. +globs: **/*.* +--- +- Use comments sparingly, and when you do, make them meaningful. +- Don't comment on obvious things. Excessive or unclear comments can clutter the codebase and become outdated. +- Use comments to convey the "why" behind specific actions or explain unusual behavior and potential pitfalls. +- Provide meaningful information about the function's behavior and explain unusual behavior and potential pitfalls. \ No newline at end of file diff --git a/.cursor/rules/follow-up-questions.mdc b/.cursor/rules/follow-up-questions.mdc new file mode 100644 index 0000000000..01f0ed6748 --- /dev/null +++ b/.cursor/rules/follow-up-questions.mdc @@ -0,0 +1,7 @@ + +--- +description: When you have questions or need clarification, I'll ask follow-up questions to ensure I understand your requirements before providing a solution. This helps me deliver more accurate and useful code that meets your specific needs. +globs: +alwaysApply: true +--- +Do not make any changes, until you have 95% confidence that you know what to build ask me follow up questions until you have that confidence. diff --git a/.cursor/rules/function-length-and-responsibility.mdc b/.cursor/rules/function-length-and-responsibility.mdc new file mode 100644 index 0000000000..ffb51c03c9 --- /dev/null +++ b/.cursor/rules/function-length-and-responsibility.mdc @@ -0,0 +1,7 @@ +--- +description: This rule enforces the single responsibility principle, ensuring functions are short and focused. +globs: **/*.* +--- +- Write short functions that only do one thing. +- Follow the single responsibility principle (SRP), which means that a function should have one purpose and perform it effectively. +- If a function becomes too long or complex, consider breaking it into smaller, more manageable functions. \ No newline at end of file diff --git a/.cursor/rules/function-ordering-conventions.mdc b/.cursor/rules/function-ordering-conventions.mdc new file mode 100644 index 0000000000..da5467dcfc --- /dev/null +++ b/.cursor/rules/function-ordering-conventions.mdc @@ -0,0 +1,5 @@ +--- +description: Defines the function ordering conventions, where functions that compose other functions appear earlier in the file, regardless of the file type. +globs: **/*.* +--- +- Order functions with those that are composing other functions appearing earlier in the file. For example, if you have a menu with multiple buttons, define the menu function above the buttons. \ No newline at end of file diff --git a/.cursor/rules/general-code-commenting.mdc b/.cursor/rules/general-code-commenting.mdc new file mode 100644 index 0000000000..cfa2867adf --- /dev/null +++ b/.cursor/rules/general-code-commenting.mdc @@ -0,0 +1,6 @@ +--- +description: Ensures helpful comments are added to the code and that old, relevant comments are preserved. +globs: **/*.* +--- +- Always add helpful comments to the code explaining what you are doing. +- Never delete old comments, unless they are no longer relevant because the code has been rewritten or deleted. \ No newline at end of file diff --git a/.cursor/rules/general-coding-principles.mdc b/.cursor/rules/general-coding-principles.mdc new file mode 100644 index 0000000000..79aa1fa597 --- /dev/null +++ b/.cursor/rules/general-coding-principles.mdc @@ -0,0 +1,7 @@ +--- +description: Applies general coding principles like simplicity, readability, performance, maintainability, testability, and reusability to all files. +globs: **/*.* +--- +- Focus on simplicity, readability, performance, maintainability, testability, and reusability. +- Remember less code is better. +- Lines of code = Debt. \ No newline at end of file diff --git a/.cursor/rules/minimal-code-changes-rule.mdc b/.cursor/rules/minimal-code-changes-rule.mdc new file mode 100644 index 0000000000..fdd8db9af6 --- /dev/null +++ b/.cursor/rules/minimal-code-changes-rule.mdc @@ -0,0 +1,7 @@ +--- +description: Enforces the principle of making minimal code changes to avoid introducing bugs or technical debt in any file. +globs: **/*.* +--- +- Only modify sections of the code related to the task at hand. +- Avoid modifying unrelated pieces of code. +- Accomplish goals with minimal code changes. \ No newline at end of file diff --git a/.cursor/rules/naming-conventions.mdc b/.cursor/rules/naming-conventions.mdc new file mode 100644 index 0000000000..d8f93836bc --- /dev/null +++ b/.cursor/rules/naming-conventions.mdc @@ -0,0 +1,7 @@ +--- +description: This rule focuses on using meaningful and descriptive names for variables, functions, and classes throughout the project. +globs: **/*.* +--- +- Choose names for variables, functions, and classes that reflect their purpose and behavior. +- A name should tell you why it exists, what it does, and how it is used. If a name requires a comment, then the name does not reveal its intent. +- Use specific names that provide a clearer understanding of what the variables represent and how they are used. \ No newline at end of file diff --git a/.cursor/rules/next-js-app-router-rule.mdc b/.cursor/rules/next-js-app-router-rule.mdc new file mode 100644 index 0000000000..22cd6a4009 --- /dev/null +++ b/.cursor/rules/next-js-app-router-rule.mdc @@ -0,0 +1,7 @@ +--- +description: Applies Next.js App Router specific guidelines to components and pages within the 'app' directory. +globs: app/**/*.tsx +alwaysApply: false +--- +- You are an expert in Next.js Pages Router. +- Follow Next.js documentation for best practices in data fetching, rendering, and routing. diff --git a/.cursor/rules/next-js-configuration-rule.mdc b/.cursor/rules/next-js-configuration-rule.mdc new file mode 100644 index 0000000000..309c654a4b --- /dev/null +++ b/.cursor/rules/next-js-configuration-rule.mdc @@ -0,0 +1,7 @@ +--- +description: Rules specifically for the Next.js configuration file. +globs: /next.config.js +alwaysApply: false +--- +- Ensure the Next.js configuration is optimized for performance. +- Review and update the configuration regularly based on project needs. \ No newline at end of file diff --git a/.cursor/rules/next-js-conventions.mdc b/.cursor/rules/next-js-conventions.mdc new file mode 100644 index 0000000000..c2abb8d595 --- /dev/null +++ b/.cursor/rules/next-js-conventions.mdc @@ -0,0 +1,12 @@ +--- +description: Key Next.js conventions for state changes, web vitals, and client-side code usage. +globs: **/*.{ts,js,jsx,tsx} +alwaysApply: false +--- +- Rely on Next.js Pages Router for state changes. +- Prioritize Web Vitals (LCP, CLS, FID). +- Minimize 'use client' usage: + - Prefer server components and Next.js SSR features. + - Use 'use client' only for Web API access in small components. + - Avoid using 'use client' for data fetching or state management. + - Refer to Next.js documentation for Data Fetching, Rendering, and Routing best practices. diff --git a/.cursor/rules/next-js-folder-structure.mdc b/.cursor/rules/next-js-folder-structure.mdc new file mode 100644 index 0000000000..ef6faca08a --- /dev/null +++ b/.cursor/rules/next-js-folder-structure.mdc @@ -0,0 +1,14 @@ +--- +description: This rule defines the recommended folder structure for Next.js projects. +globs: app/**/*.* +alwaysApply: false +--- +- Adhere to the following folder structure: + +src/ + components/ + lib/ + pages/ + hooks/ + utils/ +public/ diff --git a/.cursor/rules/performance-optimization-rules.mdc b/.cursor/rules/performance-optimization-rules.mdc new file mode 100644 index 0000000000..0a837e2d54 --- /dev/null +++ b/.cursor/rules/performance-optimization-rules.mdc @@ -0,0 +1,6 @@ +--- +description: Guidelines for optimizing performance by minimizing client-side operations and using server-side rendering. +globs: **/*.{js,jsx,ts,tsx} +--- +- Optimize Web Vitals (LCP, CLS, FID). +- Use dynamic loading for non-critical components using @src/components/dls/Spinner/Spinner.tsx \ No newline at end of file diff --git a/.cursor/rules/persona---senior-full-stack-developer.mdc b/.cursor/rules/persona---senior-full-stack-developer.mdc new file mode 100644 index 0000000000..0e66ab341b --- /dev/null +++ b/.cursor/rules/persona---senior-full-stack-developer.mdc @@ -0,0 +1,5 @@ +--- +description: Defines the persona as a senior full-stack developer with extensive knowledge applicable to all files. +globs: **/*.* +--- +- You are a senior full-stack developer. One of those rare 10x developers that has incredible knowledge. \ No newline at end of file diff --git a/.cursor/rules/pre-pr-review.mdc b/.cursor/rules/pre-pr-review.mdc new file mode 100644 index 0000000000..f396676cfc --- /dev/null +++ b/.cursor/rules/pre-pr-review.mdc @@ -0,0 +1,63 @@ +--- +description: Pre-PR code review checklist - automatically applied when reviewing code changes +globs: "**/*.ts,**/*.tsx,**/*.scss" +alwaysApply: true +--- + +# Pre-PR Code Review Standards + +When reviewing code changes, check against these Quran.com project guidelines: + +## TypeScript +- Flag any `any` types - use `unknown` or specific types +- Require explicit return types on exported functions +- Use interfaces for object shapes, types for unions +- Use enums for repeated categorical values + +## React Components +- Functional components only - no class components +- Props interface must be defined +- Use proper memoization (useCallback, useMemo) for callbacks/expensive computations +- Flag unnecessary useEffect - prefer event handlers or derived state +- Require skeleton loaders for async data to prevent layout shifts + +## Code Quality +- Functions should be under 30 lines +- Flag duplicated code - extract to reusable functions (DRY) +- Require proper error handling with meaningful fallbacks +- Flag unused imports, variables, or dead code +- Comments should explain "why" not "what" + +## API & Data Handling +- Use useSWR for data fetching, not raw fetch in components +- Handle error states with user-friendly messages +- Provide fallbacks for API responses - don't blindly trust data +- Use optimistic updates for predictable actions (bookmark, like) + +## Localization +- All user-facing text must use `t('key')` from next-translate +- No hardcoded strings in UI +- Use RTL-safe CSS logical properties (margin-inline-start, not margin-left) + +## Accessibility +- Use semantic HTML elements +- Add ARIA attributes where needed +- Ensure keyboard navigation works + +## Security +- No hardcoded secrets or credentials +- Use environment variables for configuration +- Validate and sanitize user inputs + +## Testing +- New functionality needs tests +- Cover edge cases and error scenarios +- Test file naming: `*.test.ts` or `*.test.tsx` + +## Review Output Format +When providing review feedback: +1. 🚨 **Critical** - Must fix before PR +2. 💡 **Suggestions** - Improvements to consider +3. ✅ **Good** - What's done well + +Include file paths and line numbers in feedback. diff --git a/.cursor/rules/react-functional-components.mdc b/.cursor/rules/react-functional-components.mdc new file mode 100644 index 0000000000..120c55f9c5 --- /dev/null +++ b/.cursor/rules/react-functional-components.mdc @@ -0,0 +1,6 @@ +--- +description: Enforces the use of functional components with hooks in React components. +globs: src/components/**/*.tsx +--- +- Always use React functional components with hooks. +- Use React.FC for functional components with props. \ No newline at end of file diff --git a/.cursor/rules/react.mdc b/.cursor/rules/react.mdc new file mode 100644 index 0000000000..c31598d5d6 --- /dev/null +++ b/.cursor/rules/react.mdc @@ -0,0 +1,76 @@ +--- +description: React best practices and patterns for modern web applications +globs: **/*.tsx, **/*.jsx, components/**/* +alwaysApply: false +--- + +# React Best Practices + +## Component Structure +- Use functional components over class components +- Keep components small and focused +- Extract reusable logic into custom hooks +- Use composition over inheritance +- Implement proper prop types with TypeScript +- Split large components into smaller, focused ones + +## Hooks +- Follow the Rules of Hooks +- Use custom hooks for reusable logic +- Keep hooks focused and simple +- Use appropriate dependency arrays in useEffect +- Implement cleanup in useEffect when needed +- Avoid nested hooks + +## State Management +- Use useState for local component state. +- Implement Redux Toolkit for efficient Redux development for medium-complex state logic. +- Implement slice pattern for organizing Redux code. +- Avoid prop drilling through proper state management. +- Use xstate for complex state logic. + +## Performance +- Implement proper memoization (useMemo, useCallback) +- Use React.memo for expensive components +- Avoid unnecessary re-renders +- Implement proper lazy loading +- Use proper key props in lists +- Profile and optimize render performance + +## Forms +- Re-use @src/components/FormBuilder/FormBuilder.tsx to build forms. +- Implement proper form validation. +- Handle form submission states properly. +- Show appropriate loading and error states. +- Implement proper accessibility for forms. + +## Error Handling +- Handle async errors properly +- Show user-friendly error messages +- Implement proper fallback UI +- Log errors appropriately +- Handle edge cases gracefully + +## Testing +- Write unit tests for components +- Implement integration tests for complex flows +- Use React Testing Library +- Test user interactions +- Test error scenarios +- Implement proper mock data + +## Accessibility +- Use semantic HTML elements +- Implement proper ARIA attributes +- Ensure keyboard navigation +- Test with screen readers +- Handle focus management +- Provide proper alt text for images + +## Code Organization +- Group related components together +- Use proper file naming conventions +- Implement proper directory structure +- Keep styles close to components +- Use proper imports/exports +- Document complex component logic diff --git a/.cursor/rules/redux-folder-structure.mdc b/.cursor/rules/redux-folder-structure.mdc new file mode 100644 index 0000000000..a08b7d7b02 --- /dev/null +++ b/.cursor/rules/redux-folder-structure.mdc @@ -0,0 +1,12 @@ +--- +description: Enforces specific folder structure conventions within the Redux store directory. +globs: src/redux/**/* +--- +- Follow this folder structure: + src/ + redux/ + actions/ + slices/ + RootState.ts + store.ts + migrations.ts \ No newline at end of file diff --git a/.cursor/rules/redux-toolkit-best-practices.mdc b/.cursor/rules/redux-toolkit-best-practices.mdc new file mode 100644 index 0000000000..458f754ff1 --- /dev/null +++ b/.cursor/rules/redux-toolkit-best-practices.mdc @@ -0,0 +1,9 @@ +--- +description: Applies Redux Toolkit best practices for efficient Redux development. +globs: src/redux/**/*.ts +--- +- Use Redux Toolkit for efficient Redux development. +- Implement slice pattern for organizing Redux code. +- Use selectors for accessing state in components. +- Use Redux hooks (useSelector, useDispatch) in components. +- Follow Redux style guide for naming conventions \ No newline at end of file diff --git a/.cursor/rules/typescript.mdc b/.cursor/rules/typescript.mdc new file mode 100644 index 0000000000..b3919bd95c --- /dev/null +++ b/.cursor/rules/typescript.mdc @@ -0,0 +1,57 @@ +--- +description: TypeScript coding standards and best practices for modern web development +globs: **/*.ts, **/*.tsx, **/*.d.ts +--- + +# TypeScript Best Practices + +## Type System +- Prefer interfaces over types for object definitions +- Use type for unions, intersections, and mapped types +- Avoid using `any`, prefer `unknown` for unknown types +- Use strict TypeScript configuration +- Leverage TypeScript's built-in utility types +- Use generics for reusable type patterns + +## Naming Conventions +- Use PascalCase for type names and interfaces +- Use camelCase for variables and functions +- Use UPPER_CASE for constants +- Use descriptive names with auxiliary verbs (e.g., isLoading, hasError) +- Prefix interfaces for React props with 'Props' (e.g., ButtonProps) + +## Code Organization +- Keep type definitions close to where they're used +- Export types and interfaces from dedicated type files when shared +- Use barrel exports (index.ts) for organizing exports +- Place shared types in a `types` directory +- Co-locate component props with their components + +## Functions +- Use explicit return types for public functions +- Use arrow functions for callbacks and methods +- Implement proper error handling with custom error types +- Use function overloads for complex type scenarios +- Prefer async/await over Promises + +## Best Practices +- Enable strict mode in tsconfig.json +- Use readonly for immutable properties +- Leverage discriminated unions for type safety +- Use type guards for runtime type checking +- Implement proper null checking +- Avoid type assertions unless necessary + +## Error Handling +- Create custom error types for domain-specific errors +- Use Result types for operations that can fail +- Implement proper error boundaries +- Use try-catch blocks with typed catch clauses +- Handle Promise rejections properly + +## Patterns +- Use the Builder pattern for complex object creation +- Implement the Repository pattern for data access +- Use the Factory pattern for object creation +- Leverage dependency injection +- Use the Module pattern for encapsulation \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index d862f96f1b..c5500558ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,5 @@ Dockerfile node_modules npm-debug.log README.md -.next \ No newline at end of file +.next +.git diff --git a/.env.example b/.env.example index f5c8bf59e9..4255d6581f 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true NEXT_PUBLIC_SENTRY_URL="" NEXT_PUBLIC_SENTRY_PROJECT="" +NEXT_PUBLIC_QURAN_REFLECT_URL=https://quranreflect.com +NEXT_PUBLIC_SSO_ENABLED=false NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed SIGNATURE_TOKEN=1234 diff --git a/.eslintrc.json b/.eslintrc.json index 59387a7136..d6123e7f2a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -157,7 +157,14 @@ "import/no-extraneous-dependencies": [ "error", { - "devDependencies": ["**/*.test.ts", "**/*.test.tsx", "tests/**/*"] + "devDependencies": [ + "**/*.test.ts", + "**/*.test.tsx", + "tests/**/*", + "**/playwright.config.{ts,js,mjs,cjs}", + "playwright.config.{ts,js,mjs,cjs}", + "playwright/**" + ] } ], "@typescript-eslint/ban-ts-comment": 0, @@ -196,6 +203,13 @@ "i18next/no-literal-string": 0 } }, + { + "files": ["tests/**/*.{ts,tsx,js}"], + "rules": { + "react-func/max-lines-per-function": "off", + "max-lines": "off" + } + }, { "files": ["remotion/*.{ts,tsx}"], "extends": ["plugin:@remotion/recommended"] diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..491d67ceb0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,167 @@ +# Quran.com Frontend Code Review Guidelines + +This file defines repository-wide code review standards for GitHub Copilot. + +## Purpose & Scope + +Copilot should review all pull requests against these standards to ensure code quality, +maintainability, and alignment with the Quran.com mission of helping millions of Muslims engage with +the Qur'an. + +--- + +## PR Structure Requirements + +- Flag PRs that appear to contain multiple unrelated changes (should be single-scope) +- Verify PR title follows format: `[QF-XXX] Brief description` +- Check that new environment variables are documented in PR description +- Ensure commits have meaningful, concise messages + +## TypeScript Standards + +- Flag usage of `any` type - prefer `unknown` or specific types +- Require explicit return types for public/exported functions +- Prefer `interface` over `type` for object definitions +- Use `type` for unions, intersections, and mapped types +- Require enums for repeated raw values of the same category + +```typescript +// Good: Use enums for categorical values +enum GoalType { + QURAN_TIME = 'QURAN_TIME', + QURAN_PAGES = 'QURAN_PAGES', + QURAN_RANGE = 'QURAN_RANGE', +} + +// Bad: Using raw strings repeatedly +const goalType = 'QURAN_TIME'; +``` + +## Code Quality + +- Flag functions exceeding 30 lines - should be split +- Identify duplicated code that should be extracted to reusable functions (DRY principle) +- Flag unused imports, variables, or dead code +- Require proper error handling with meaningful fallbacks +- Flag skipped linting rules without clear justification +- Ensure complex logic has inline comments explaining "why" + +## React & Next.js Patterns + +- Require functional components only (no class components) +- Flag unnecessary `useEffect` usage +- Ensure proper memoization with `useMemo` and `useCallback` where appropriate +- Flag prop drilling - suggest proper state management +- Require skeleton loaders or placeholders for async data to prevent layout shifts +- Flag JavaScript-based responsive styling - prefer CSS media queries +- Ensure Radix UI components are used instead of custom implementations when available + +## API & Data Handling + +- Flag unnecessary API calls - suggest caching with `useSWR` +- Require optimistic UI updates for predictable interactions +- Flag redundant state when `useSWR` already manages cached data +- Ensure proper error states and fallbacks for failed API requests +- Flag blind trust in API responses without fallbacks + +## Localization & Accessibility + +- All user-facing text must use `next-translate` localization +- Flag hardcoded strings that should be localized +- Ensure RTL (right-to-left) support is maintained +- Require semantic HTML elements +- Flag missing ARIA attributes where needed + +## Testing Requirements + +- Flag PRs with new functionality but no corresponding tests +- Ensure test coverage for edge cases and error scenarios +- Verify test file naming follows `*.test.ts` or `*.test.tsx` pattern +- Flag tests without meaningful assertions + +## Security + +- Flag any hardcoded credentials or secrets +- Ensure environment variables are used for configuration +- Flag missing input validation or sanitization +- Ensure proper authentication checks for protected routes + +## Performance + +- Flag large bundle size impacts from new dependencies +- Require lazy loading for non-critical components +- Check for proper memoization to prevent unnecessary re-renders +- Flag components without proper cleanup in `useEffect` + +## Documentation + +- Require JSDoc comments for complex utility functions +- Ensure README updates when adding new features or setup changes +- Flag undocumented complex business logic + +--- + +## Code Examples + +### Error Handling Pattern + +```typescript +// Good: Proper error handling with fallback +const fetchData = async () => { + try { + const response = await api.getData(); + return response.data; + } catch (error) { + logError('Failed to fetch data', error); + return defaultValue; // Meaningful fallback + } +}; + +// Bad: No error handling +const fetchData = async () => { + const response = await api.getData(); + return response.data; +}; +``` + +### Component Structure + +```tsx +// Good: Focused component with proper typing +interface UserCardProps { + user: User; + onSelect: (userId: string) => void; +} + +const UserCard: React.FC = ({ user, onSelect }) => { + const handleClick = useCallback(() => { + onSelect(user.id); + }, [user.id, onSelect]); + + return ( + + ); +}; + +// Bad: Missing types, inline handlers +const UserCard = ({ user, onSelect }) => ( + +); +``` + +### Localization + +```tsx +// Good: Localized text +import useTranslation from 'next-translate/useTranslation'; + +const WelcomeMessage = () => { + const { t } = useTranslation('common'); + return

{t('welcome-message')}

; +}; + +// Bad: Hardcoded text +const WelcomeMessage = () =>

Welcome to Quran.com

; +``` diff --git a/.github/instructions/api-data.instructions.md b/.github/instructions/api-data.instructions.md new file mode 100644 index 0000000000..c8307ec8e0 --- /dev/null +++ b/.github/instructions/api-data.instructions.md @@ -0,0 +1,71 @@ +--- +applyTo: 'src/api.ts,src/hooks/**/*.ts,src/services/**/*.ts,src/utils/api/**/*.ts' +--- + +# API & Data Handling Review Standards + +Guidelines for API calls and data handling in the Quran.com frontend. + +## Data Fetching + +- Require `useSWR` for data fetching - flag raw fetch/axios calls in components +- Use `useSWRImmutable` for data that rarely changes (chapters list, translations list) +- Flag redundant API calls - suggest caching strategies + +```typescript +// Good - using useSWR +const { data, error, isLoading } = useSWR(makeVersesUrl(chapterId), fetcher); + +// Bad - manual fetching without caching +useEffect(() => { + fetch(makeVersesUrl(chapterId)) + .then((res) => res.json()) + .then(setData); +}, [chapterId]); +``` + +## State Management + +- Flag using Redux/store when `useSWR` cache is sufficient +- Require optimistic updates for predictable actions (like, bookmark, etc.) + +```typescript +// Good - optimistic update +const handleBookmark = async () => { + mutate(key, optimisticData, false); // Update UI immediately + await addBookmark(verseKey); + mutate(key); // Revalidate +}; +``` + +## Error Handling + +- Require error states for all API-dependent components +- Flag blind trust in API responses - always provide fallbacks +- Ensure meaningful error messages for users + +```typescript +// Good +if (error) { + return ; +} +if (!data) { + return ; +} + +// Bad - no error handling +return ; +``` + +## Response Validation + +- Flag direct usage of API response without null checks +- Require fallback values for optional response fields + +```typescript +// Good +const versesCount = response?.pagination?.totalRecords ?? 0; + +// Bad +const versesCount = response.pagination.totalRecords; +``` diff --git a/.github/instructions/localization.instructions.md b/.github/instructions/localization.instructions.md new file mode 100644 index 0000000000..37802bc60d --- /dev/null +++ b/.github/instructions/localization.instructions.md @@ -0,0 +1,67 @@ +--- +applyTo: 'src/components/**/*.tsx,src/pages/**/*.tsx' +--- + +# Localization Review Standards + +Guidelines for internationalization in the Quran.com frontend. + +## Text Content + +- Flag ALL hardcoded user-facing strings - must use `next-translate` +- Only English locale files should be modified - other locales use Lokalise + +```tsx +// Good +import useTranslation from 'next-translate/useTranslation'; + +const { t } = useTranslation('common'); +return

{t('verse.bookmark-added')}

; + +// Bad - hardcoded string +return

Bookmark added successfully

; +``` + +## Translation Keys + +- Use descriptive, hierarchical key names +- Group related translations under common prefixes + +```typescript +// Good +t('reading.settings.font-size'); +t('reading.settings.theme'); + +// Bad +t('fontSize'); +t('themeOption'); +``` + +## RTL Support + +- Flag CSS that may break RTL layouts +- Use logical properties instead of physical ones +- Ensure text alignment respects direction + +```scss +// Good - logical properties +margin-inline-start: 1rem; +padding-inline-end: 0.5rem; + +// Bad - physical properties (breaks RTL) +margin-left: 1rem; +padding-right: 0.5rem; +``` + +## Dynamic Content + +- Ensure interpolation is used for dynamic values in translations +- Flag string concatenation with translated text + +```typescript +// Good +t('verse.ayah-number', { number: verseNumber }); + +// Bad - string concatenation +t('verse.ayah') + ' ' + verseNumber; +``` diff --git a/.github/instructions/playwright-tests.instructions.md b/.github/instructions/playwright-tests.instructions.md new file mode 100644 index 0000000000..ce42642bd3 --- /dev/null +++ b/.github/instructions/playwright-tests.instructions.md @@ -0,0 +1,121 @@ +# Playwright Tests - AI Guide + +This doc is for an AI writing Playwright tests from scratch in this repo. Follow it to keep tests +consistent, stable, and fast. + +## Where things live + +- Integration tests: `tests/integration/**` +- Logged-in tests: `tests/integration/loggedin/**` +- Auth setup (creates storage state): `tests/auth.setup.ts` +- Page objects (POM): `tests/POM/**` +- Shared helpers: `tests/helpers/**` +- Central test ids: `tests/test-ids.ts` +- Mocking (MSW): `tests/mocks/msw/**` +- Playwright config: `playwright.config.ts` + +## Running tests + +- All integration: `yarn test:integration` +- Single file: `yarn test:integration tests/integration/.spec.ts` +- Base URL: `PLAYWRIGHT_TEST_BASE_URL` or `http://localhost:` + +## Auth and logged-in tests + +- Logged-in tests run in the `loggedin` project and use `playwright/.auth/user.json`. +- `tests/auth.setup.ts` creates that file using `TEST_USER_EMAIL` and `TEST_USER_PASSWORD`. +- If those env vars are missing, logged-in tests will fail. + +## Logged-in prerequisites (shared account state) + +Logged-in tests run against a shared account that can carry state across runs. Do not assume any +user preferences are still at defaults. Always set prerequisites explicitly before assertions. + +- If a test asserts English text, call `ensureEnglishLanguage(page)` at the start. +- If a test depends on selected translations, reset them via + `clearSelectedTranslations(page, { isMobile })` and then add exactly what you need with + `selectTranslationPreference(page, translationId, { isMobile })`. +- For other user settings (theme, word-by-word toggles, mushaf lines), set them explicitly using + helpers in `tests/helpers/settings.ts`. Do not rely on previous test state. + +## Selector strategy + +Prefer stable, user-facing queries: + +- `page.getByRole(...)` for buttons/links/inputs. +- `page.getByTestId(...)` with `TestId` from `tests/test-ids.ts`. +- Use helper builders for dynamic ids: `getVerseTestId`, `getChapterContainerTestId`, + `getLanguageItemTestId`, etc. + +Avoid brittle CSS selectors. If multiple nodes share a test id, scope the locator or use `:visible` +to pick the rendered instance. + +## Shared helpers (reuse these) + +Settings and UI: + +- `tests/helpers/settings.ts` + - `changeWebsiteTheme`, `withSettingsDrawer`, `selectQuranFont`, `selectMushafLines`, word-by-word + helpers. +- `tests/helpers/navigation.ts` + - `openNavigationDrawer`, `openSearchDrawer`, `openQuranNavigation`. +- `tests/helpers/language.ts` + - `selectNavigationDrawerLanguage`, `ensureEnglishLanguage`. +- `tests/helpers/mode-switching.ts` + - `switchToReadingMode`, `switchToTranslationMode`. +- `tests/helpers/banner.ts` + - `clickCreateMyGoalButton`. +- `tests/helpers/streak-api-mocks.ts` + - `mockStreakWithGoal`, `mockStreakWithoutGoal`. + +Page objects: + +- `tests/POM/home-page.ts` (common navigation helpers, settings, search). +- `tests/POM/mushaf-mode.ts`, `tests/POM/audio-utilities.ts`. + +If a helper exists, use it. If not, add a new helper instead of duplicating logic across specs. + +## Test structure and style + +- Follow AAA: Arrange, Act, Assert. +- Use `test.describe` to group cases. +- Use tags when relevant: `@slow`, `@auth`, `@profile`, `@smoke`, etc. +- Prefer `await expect(locator).toBeVisible()` over `waitForTimeout`. +- For navigation checks: `await Promise.all([action, page.waitForURL(...)])`. + +## Mocking and network + +- For shared API mocks, extend `tests/mocks/msw/handlers.js`. +- For test-specific API behavior, use `page.route(...)` inside the spec. + +## Adding new test ids + +- Add `data-testid` in the component (use existing patterns in `src`). +- Mirror the id in `tests/test-ids.ts` and use the enum or helper functions. + +## Common pitfalls + +- Form submit buttons can be disabled until the form is valid. If you need validation errors, fill + inputs and blur them instead of clicking a disabled button. +- Hidden checkboxes should be toggled via the label (use `setCheckboxValue` in + `tests/helpers/settings.ts`). +- Some UI elements exist twice (mobile/desktop). Use scoped locators or `:visible`. + +## Minimal test template + +```ts +import { expect, test } from '@playwright/test'; + +import Homepage from '@/tests/POM/home-page'; +import { TestId } from '@/tests/test-ids'; + +test.describe('Feature X', () => { + test('does Y', async ({ page, context }) => { + const home = new Homepage(page, context); + await home.goTo('/'); + + await page.getByTestId(TestId.NAVIGATE_QURAN_BUTTON).click(); + await expect(page.getByTestId(TestId.NAVIGATION_DRAWER)).toBeVisible(); + }); +}); +``` diff --git a/.github/instructions/react-components.instructions.md b/.github/instructions/react-components.instructions.md new file mode 100644 index 0000000000..9a51023ea1 --- /dev/null +++ b/.github/instructions/react-components.instructions.md @@ -0,0 +1,80 @@ +--- +applyTo: 'src/components/**/*.tsx,src/pages/**/*.tsx' +--- + +# React Component Review Standards + +Guidelines for React component reviews in the Quran.com frontend. + +## Component Structure + +- Require functional components only - flag any class components +- Flag components exceeding 150 lines - should be split +- Ensure single responsibility - one component, one purpose +- Flag prop drilling beyond 2 levels - suggest context or state management + +## Props & Types + +- Require explicit Props interface for all components +- Flag missing or incomplete prop types +- Ensure default values for optional props when appropriate + +```tsx +// Good +interface VerseCardProps { + verse: Verse; + showTranslation?: boolean; + onBookmark: (verseKey: string) => void; +} + +const VerseCard: React.FC = ({ verse, showTranslation = true, onBookmark }) => { + // ... +}; + +// Bad - missing types +const VerseCard = ({ verse, showTranslation, onBookmark }) => { + // ... +}; +``` + +## Hooks Usage + +- Flag `useEffect` without cleanup when needed (subscriptions, timers) +- Flag `useEffect` that could be replaced with event handlers +- Require proper dependency arrays - flag missing dependencies +- Flag unnecessary state - derive values when possible + +```tsx +// Good - derived value +const isComplete = progress === 100; + +// Bad - unnecessary state +const [isComplete, setIsComplete] = useState(false); +useEffect(() => { + setIsComplete(progress === 100); +}, [progress]); +``` + +## Memoization + +- Require `useCallback` for functions passed to child components +- Require `useMemo` for expensive computations +- Flag over-memoization where unnecessary + +## Radix UI Components + +- Flag custom implementations of common UI patterns +- Suggest Radix UI alternatives: Dialog, Dropdown, Tooltip, Popover, etc. + +## Loading States + +- Require skeleton loaders or placeholders for async data +- Flag missing loading states that could cause layout shifts +- Ensure proper error boundaries for error states + +## Accessibility + +- Require semantic HTML elements +- Flag missing ARIA labels on interactive elements +- Ensure keyboard navigation support +- Flag images without alt text diff --git a/.github/instructions/security.instructions.md b/.github/instructions/security.instructions.md new file mode 100644 index 0000000000..435f5dd514 --- /dev/null +++ b/.github/instructions/security.instructions.md @@ -0,0 +1,47 @@ +--- +applyTo: '**/*.ts,**/*.tsx,**/*.js' +--- + +# Security Review Standards + +Guidelines for security in the Quran.com frontend. + +## Credentials & Secrets + +- Flag ANY hardcoded credentials, API keys, or secrets +- Ensure all sensitive values use environment variables +- Flag commits that may contain secrets + +```typescript +// Good +const apiKey = process.env.NEXT_PUBLIC_API_KEY; + +// Bad - NEVER do this +const apiKey = 'sk-1234567890abcdef'; +``` + +## Environment Variables + +- Document new environment variables in PR description +- Use `NEXT_PUBLIC_` prefix only for client-side variables +- Flag sensitive data exposed to client + +## Input Validation + +- Require validation for all user inputs +- Flag direct usage of user input without sanitization +- Ensure proper encoding for dynamic content + +```typescript +// Good +const sanitizedInput = DOMPurify.sanitize(userInput); + +// Bad - XSS vulnerability +dangerouslySetInnerHTML={{ __html: userInput }} +``` + +## Authentication + +- Flag protected routes without auth checks +- Ensure proper session validation +- Flag sensitive operations without authentication diff --git a/.github/instructions/styles.instructions.md b/.github/instructions/styles.instructions.md new file mode 100644 index 0000000000..d016dee315 --- /dev/null +++ b/.github/instructions/styles.instructions.md @@ -0,0 +1,81 @@ +--- +applyTo: '**/*.scss,**/*.module.scss' +--- + +# SCSS Styling Review Standards + +Guidelines for styling in the Quran.com frontend. + +## File Structure + +- Each component should have its own `.module.scss` file +- Flag shared stylesheets that should be component-specific +- Co-locate styles with their components + +## Responsive Design + +- Use CSS media queries for responsive styling +- Flag JavaScript-based responsive logic that should be CSS +- Ensure mobile-first approach + +```scss +// Good - CSS media queries +.container { + padding: 1rem; + + @media (min-width: 768px) { + padding: 2rem; + } +} + +// Bad - should not rely on JS for this +``` + +## RTL Support + +- Use logical CSS properties for RTL compatibility +- Flag physical directional properties + +```scss +// Good +.icon { + margin-inline-end: 0.5rem; +} + +// Bad - breaks RTL +.icon { + margin-right: 0.5rem; +} +``` + +## Design Tokens + +- Use CSS variables for colors, spacing, and typography +- Flag hardcoded color values +- Ensure consistency with design system + +```scss +// Good +.text { + color: var(--color-text-primary); + font-size: var(--font-size-base); +} + +// Bad - hardcoded values +.text { + color: #333333; + font-size: 16px; +} +``` + +## Naming Conventions + +- Use descriptive class names +- Follow BEM-like naming when appropriate +- Flag overly generic class names + +## Layout Shifts + +- Ensure elements have explicit dimensions when needed +- Flag layouts that may cause CLS (Cumulative Layout Shift) +- Use aspect-ratio for media containers diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000000..27bc7e3fc9 --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,70 @@ +--- +applyTo: '**/*.test.ts,**/*.test.tsx,tests/**/*' +--- + +# Testing Review Standards + +Guidelines for tests in the Quran.com frontend. + +## Test Coverage + +- Flag new functionality without corresponding tests +- Require tests for utility functions +- Require tests for custom hooks +- Ensure edge cases and error scenarios are covered + +## Test Structure + +- Follow AAA pattern: Arrange, Act, Assert +- Use descriptive test names that explain the expected behavior +- Group related tests with `describe` blocks + +```typescript +// Good +describe('BookmarkButton', () => { + describe('when user is logged in', () => { + it('should add bookmark when clicked', async () => { + // Arrange + const onBookmark = vi.fn(); + render(); + + // Act + await userEvent.click(screen.getByRole('button')); + + // Assert + expect(onBookmark).toHaveBeenCalledOnce(); + }); + }); +}); + +// Bad - vague test name +it('should work', () => { + // ... +}); +``` + +## Assertions + +- Flag tests without meaningful assertions +- Ensure assertions test the actual behavior, not implementation details +- Prefer user-centric queries (getByRole, getByText) + +```typescript +// Good - testing user-visible behavior +expect(screen.getByText('Bookmarked')).toBeInTheDocument(); + +// Bad - testing implementation +expect(component.state.isBookmarked).toBe(true); +``` + +## Mocking + +- Mock external dependencies (API calls, third-party services) +- Flag tests that make real network requests +- Use MSW for API mocking when appropriate + +## File Naming + +- Test files must follow `*.test.ts` or `*.test.tsx` pattern +- Co-locate unit tests with source files +- Place integration tests in `tests/integration/` diff --git a/.github/instructions/typescript.instructions.md b/.github/instructions/typescript.instructions.md new file mode 100644 index 0000000000..537ec7b6f9 --- /dev/null +++ b/.github/instructions/typescript.instructions.md @@ -0,0 +1,86 @@ +--- +applyTo: '**/*.ts,**/*.tsx' +--- + +# TypeScript Code Review Standards + +Guidelines for TypeScript code reviews specific to the Quran.com frontend. + +## Type Safety + +- Flag any usage of `any` type - suggest `unknown` or specific types instead +- Require explicit return types for all exported/public functions +- Flag implicit `any` in function parameters +- Ensure strict null checks are handled properly + +## Interface vs Type + +- Prefer `interface` for object shape definitions +- Use `type` only for unions, intersections, and mapped types +- Flag `type` usage where `interface` is more appropriate + +```typescript +// Good +interface UserPreferences { + theme: 'light' | 'dark'; + fontSize: number; +} + +// Bad +type UserPreferences = { + theme: 'light' | 'dark'; + fontSize: number; +}; +``` + +## Enums for Constants + +- Require enums when the same raw values appear multiple times +- Flag repeated string/number literals that should be enums + +```typescript +// Good +enum ReadingMode { + READING = 'reading', + TRANSLATION = 'translation', + LISTENING = 'listening', +} + +// Bad - repeated strings +const mode1 = 'reading'; +const mode2 = 'translation'; +``` + +## Naming Conventions + +- Use `PascalCase` for types, interfaces, enums, and components +- Use `camelCase` for variables, functions, and methods +- Use `UPPER_SNAKE_CASE` for constants +- Prefix Props interfaces with component name: `ButtonProps`, `ModalProps` + +## Error Handling + +- Flag unhandled Promise rejections +- Require try-catch blocks for async operations +- Ensure error types are properly typed, not `any` + +```typescript +// Good +try { + const data = await fetchVerses(chapterId); + return data; +} catch (error) { + logError('Failed to fetch verses', error); + return fallbackData; +} + +// Bad - no error handling +const data = await fetchVerses(chapterId); +return data; +``` + +## Imports + +- Flag unused imports +- Ensure imports are alphabetized within groups +- Group order: React → External packages → Internal modules → Types diff --git a/.github/prompts/pre-pr-review.md b/.github/prompts/pre-pr-review.md new file mode 100644 index 0000000000..e88378be9e --- /dev/null +++ b/.github/prompts/pre-pr-review.md @@ -0,0 +1,124 @@ +# Pre-PR Code Review + +Run an AI code review before opening a PR. + +## Quick Commands + +| IDE | Command | +|-----|---------| +| **VS Code** | Open Copilot Chat → Type `/review` | +| **Cursor** | Open Chat (Cmd+L) → Type `/review` | +| **Windsurf** | Open Cascade → Type `/review` | + +## Alternative: VS Code Task + +All IDEs support VS Code tasks: + +1. Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) +2. Type "Tasks: Run Task" +3. Select **"Pre-PR Review"** (staged changes) or **"Pre-PR Review (All Changes)"** + +## How It Works + +Each IDE has review prompts configured: + +- VS Code: `.github/prompts/review.prompt.md` +- Cursor: `.cursor/prompts/review.prompt.md` +- Windsurf: `.windsurf/prompts/review.prompt.md` + +Project rules are also applied automatically from `.cursor/rules/` and `.windsurf/rules/`. + +--- + +## Manual Prompt (for other AI tools) + +``` +You are a code reviewer for the Quran.com frontend project. Review my changes against our project guidelines before I open a PR. + +## Project Context +- Next.js application with Pages Router +- TypeScript with strict mode +- SCSS modules for styling +- next-translate for i18n +- useSWR for data fetching +- Radix UI for accessible components + +## Review Checklist + +### TypeScript +- [ ] No `any` types (use `unknown` or specific types) +- [ ] Explicit return types on exported functions +- [ ] Interfaces for object shapes, types for unions +- [ ] Enums for repeated categorical values + +### React Components +- [ ] Functional components only +- [ ] Props interface defined +- [ ] Proper memoization (useCallback, useMemo) +- [ ] No unnecessary useEffect +- [ ] Skeleton loaders for async data + +### Code Quality +- [ ] Functions under 30 lines +- [ ] No duplicated code (DRY) +- [ ] Proper error handling with fallbacks +- [ ] No unused imports/variables +- [ ] Comments explain "why" not "what" + +### API & Data +- [ ] Using useSWR for data fetching +- [ ] Error states handled +- [ ] Fallbacks for API responses +- [ ] Optimistic updates where appropriate + +### Localization +- [ ] All text uses next-translate (t('key')) +- [ ] No hardcoded user-facing strings +- [ ] RTL-safe CSS (logical properties) + +### Accessibility +- [ ] Semantic HTML elements +- [ ] ARIA attributes where needed +- [ ] Keyboard navigation works + +### Security +- [ ] No hardcoded secrets +- [ ] Environment variables for config +- [ ] Input validation present + +### Testing +- [ ] Tests for new functionality +- [ ] Edge cases covered +- [ ] Error scenarios tested + +## My Changes +[DESCRIBE YOUR CHANGES HERE] + +## Files Changed +[PASTE YOUR DIFF OR LIST FILES] + +--- + +Please review and provide: +1. **Critical Issues** - Must fix before PR +2. **Suggestions** - Improvements to consider +3. **Positive Feedback** - What's done well + +Format issues with file paths and line numbers when possible. +``` + +--- + +## Quick Terminal Command + +Add this alias to your shell config for quick reviews: + +```bash +# Fish shell (~/.config/fish/config.fish) +alias prereview="git diff --staged | pbcopy && echo 'Staged changes copied. Paste into AI chat with the review prompt.'" + +# Bash/Zsh (~/.bashrc or ~/.zshrc) +alias prereview="git diff --staged | pbcopy && echo 'Staged changes copied. Paste into AI chat with the review prompt.'" +``` + +Then run `prereview` before opening a PR to copy your staged changes. diff --git a/.github/prompts/review.prompt.md b/.github/prompts/review.prompt.md new file mode 100644 index 0000000000..d5716b90a4 --- /dev/null +++ b/.github/prompts/review.prompt.md @@ -0,0 +1,36 @@ +Review my code changes against the Quran.com frontend project guidelines. + +## Context + +- Next.js (Pages Router), TypeScript strict mode, SCSS modules +- next-translate for i18n, useSWR for data fetching, Radix UI components + +## Check For + +**TypeScript**: No `any` types, explicit return types on exports, interfaces for objects, enums for +repeated values + +**React**: Functional components only, Props interface, proper memoization, no unnecessary +useEffect, skeleton loaders for async data + +**Code Quality**: Functions <30 lines, DRY code, proper error handling with fallbacks, no unused +code, comments explain "why" + +**API/Data**: useSWR for fetching, error states handled, API response fallbacks, optimistic updates + +**Localization**: All text uses `t('key')` from next-translate, no hardcoded strings, RTL-safe CSS +(logical properties like margin-inline-start) + +**Accessibility**: Semantic HTML, ARIA attributes, keyboard navigation + +**Security**: No hardcoded secrets, env vars for config, input validation + +**Testing**: Tests for new functionality, edge cases, error scenarios + +## Output Format + +1. **🚨 Critical** - Must fix before PR +2. **💡 Suggestions** - Improvements to consider +3. **✅ Good** - What's done well + +Include file paths and line numbers. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 534cd8bcf6..5afc3df568 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,33 +1,119 @@ -# Summary +## Summary -Fixes #JIRA-TICKET + -A brief description for the PR. +Closes: [QF-XXXX](https://quranfoundation.atlassian.net/browse/QF-XXXX) -## Type of change +## Type of Change -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] This change requires a documentation update +- [ ] 🐛 Bug fix (non-breaking change which fixes an issue) +- [ ] ✨ New feature (non-breaking change which adds functionality) +- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as + expected) +- [ ] 📝 Documentation update +- [ ] ♻️ Refactoring (no functional changes) -## Test plan +### If Breaking Change -This should state how this PR have been tested. + -## Checklist +## Scope Confirmation -- [ ] My code follows the style guidelines of this project -- [ ] I have performed a self-review of my code -- [ ] My changes generate no new warnings -- [ ] Any dependent changes have been merged and published in downstream modules -- [ ] I have commented on my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] New and existing unit tests pass locally with my changes +- [ ] This PR addresses **one** feature/fix only +- [ ] If multiple changes were needed, they are split into separate PRs -## Screenshots or videos +## Environment Variables + + + +| Variable | Description | Required | +| ------------- | ---------------- | -------- | +| `EXAMPLE_VAR` | Description here | Yes/No | + +## Rollback Safety + +- [ ] Can be safely reverted without data issues or migrations +- [ ] Rollback requires special steps (describe below): + + + +## Test Plan + + + +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] Manual testing performed + +**Testing steps:** + +1. Step one +2. Step two + +### Edge Cases Verified + +- [ ] ⏳ Loading state handled +- [ ] ❌ Error state handled +- [ ] 📭 Empty state handled +- [ ] 👤 Logged-in vs guest behavior (if applicable) + +## Pre-Review Checklist + + + +### Code Quality + +- [ ] I have performed a **self-review** of my code (file by file) +- [ ] My code follows the [project style guidelines](/.github/copilot-instructions.md) +- [ ] No `any` types used (or justified if unavoidable) +- [ ] No unused code, imports, or dead code included +- [ ] Complex logic has inline comments explaining "why" +- [ ] Functions are under 30 lines and follow single responsibility + +### Testing & Validation + +- [ ] All tests pass locally (`yarn test`) +- [ ] Linting passes (`yarn lint`) +- [ ] Build succeeds (`yarn build`) +- [ ] Edge cases and error scenarios are handled + +### Documentation + +- [ ] Code is self-documenting with clear naming +- [ ] README updated (if adding features or setup changes) +- [ ] Inline comments added for complex logic + +### Localization (if UI changes) + +- [ ] All user-facing text uses `next-translate` +- [ ] Only English locale files modified (Lokalise handles others) +- [ ] RTL layout verified + +### Accessibility (if UI changes) + +- [ ] Semantic HTML elements used +- [ ] ARIA attributes added where needed +- [ ] Keyboard navigation works + +## Screenshots/Videos + + | Before | After | -| ------ | ------ | -| IMAGE HERE | IMAGE HERE | +| ------ | ----- | +| | | + +## Related PRs + + + +## Reviewer Notes + + + +## AI Assistance Disclosure + + + +- [ ] AI tools were NOT used for this PR +- [ ] AI tools were used, and I have **thoroughly reviewed and validated** all generated code diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 51f69dc9e7..394995a036 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,30 +1,45 @@ name: Playwright Tests on: - deployment_status: - branches: [master] + push: + branches: [master, main, develop, testing] + paths: + - 'src/**' + - 'tests/**' + - 'playwright.config.ts' + - '.github/workflows/playwright.yml' + pull_request: + branches: [master, main, develop] jobs: test_integration: name: Playwright Tests - timeout-minutes: 60 runs-on: ubuntu-latest - # if: ${{ github.event.deployment_status.state == 'success' }} - if: ${{ false }} # disable it for now + if: false # Temporarily disable this workflow steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: node-version: 18 - cache: "yarn" + cache: 'yarn' + - name: Install dependencies run: yarn install --frozen-lockfile - - name: Install Playwright + + - name: Build application + run: yarn build + + - name: Install Playwright browsers run: npx playwright install --with-deps + - name: Run Playwright tests - run: yarn run test:integration + run: yarn test:integration env: - PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }} CI: true - - uses: actions/upload-artifact@v2 + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 if: always() with: name: playwright-report diff --git a/.gitignore b/.gitignore index 113bdd86e9..490ada8438 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ public/robots.txt # playwright test-results/ playwright-report/ +playwright/.auth/* keys diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..23f616968a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Pre-PR Review", + "type": "shell", + "command": "echo '🔍 Pre-PR Code Review\\n\\nReview the following staged changes against Quran.com project guidelines:\\n' && git diff --staged && echo '\\n---\\nGuidelines: .github/copilot-instructions.md\\nCheck for: TypeScript types, React patterns, error handling, localization, accessibility, security, tests.'", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new", + "focus": true + }, + "group": "none" + }, + { + "label": "Pre-PR Review (All Changes)", + "type": "shell", + "command": "echo '🔍 Pre-PR Code Review\\n\\nReview the following changes against Quran.com project guidelines:\\n' && git diff && echo '\\n---\\nGuidelines: .github/copilot-instructions.md\\nCheck for: TypeScript types, React patterns, error handling, localization, accessibility, security, tests.'", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new", + "focus": true + }, + "group": "none" + } + ] +} diff --git a/.windsurf/prompts/review.prompt.md b/.windsurf/prompts/review.prompt.md new file mode 100644 index 0000000000..c7d3540389 --- /dev/null +++ b/.windsurf/prompts/review.prompt.md @@ -0,0 +1,32 @@ +Review my code changes against the Quran.com frontend project guidelines. + +## Context + +- Next.js (Pages Router), TypeScript strict mode, SCSS modules +- next-translate for i18n, useSWR for data fetching, Radix UI components + +## Check For + +**TypeScript**: No `any` types, explicit return types on exports, interfaces for objects, enums for repeated values + +**React**: Functional components only, Props interface, proper memoization, no unnecessary useEffect, skeleton loaders for async data + +**Code Quality**: Functions <30 lines, DRY code, proper error handling with fallbacks, no unused code, comments explain "why" + +**API/Data**: useSWR for fetching, error states handled, API response fallbacks, optimistic updates + +**Localization**: All text uses `t('key')` from next-translate, no hardcoded strings, RTL-safe CSS (logical properties like margin-inline-start) + +**Accessibility**: Semantic HTML, ARIA attributes, keyboard navigation + +**Security**: No hardcoded secrets, env vars for config, input validation + +**Testing**: Tests for new functionality, edge cases, error scenarios + +## Output Format + +1. **🚨 Critical** - Must fix before PR +2. **💡 Suggestions** - Improvements to consider +3. **✅ Good** - What's done well + +Include file paths and line numbers. diff --git a/.windsurf/rules/bug-handling-with-todo-comments.md b/.windsurf/rules/bug-handling-with-todo-comments.md new file mode 100644 index 0000000000..2fe9a77899 --- /dev/null +++ b/.windsurf/rules/bug-handling-with-todo-comments.md @@ -0,0 +1,6 @@ +--- +trigger: model_decision +description: Specifies the usage of TODO comments to outline problems or bugs encountered in existing code, regardless of file type. +globs: **/*.* +--- +- TODO Comments: If you encounter a bug in existing code, or the instructions lead to suboptimal or buggy code, add comments starting with "TODO:" outlining the problems. \ No newline at end of file diff --git a/.windsurf/rules/clean-code.md b/.windsurf/rules/clean-code.md new file mode 100644 index 0000000000..25955b196d --- /dev/null +++ b/.windsurf/rules/clean-code.md @@ -0,0 +1,56 @@ +--- +trigger: model_decision +description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality. +globs: +--- +# Clean Code Guidelines + +## Constants Over Magic Numbers +- Replace hard-coded values with named constants +- Use descriptive constant names that explain the value's purpose +- Keep constants at the top of the file or in a dedicated constants file + +## Meaningful Names +- Variables, functions, and classes should reveal their purpose +- Names should explain why something exists and how it's used +- Avoid abbreviations unless they're universally understood + +## Smart Comments +- Don't comment on what the code does - make the code self-documenting +- Use comments to explain why something is done a certain way +- Document APIs, complex algorithms, and non-obvious side effects + +## Single Responsibility +- Each function should do exactly one thing +- Functions should be small and focused +- If a function needs a comment to explain what it does, it should be split + +## DRY (Don't Repeat Yourself) +- Extract repeated code into reusable functions +- Share common logic through proper abstraction +- Maintain single sources of truth + +## Clean Structure +- Keep related code together +- Organize code in a logical hierarchy +- Use consistent file and folder naming conventions + +## Encapsulation +- Hide implementation details +- Expose clear interfaces +- Move nested conditionals into well-named functions + +## Code Quality Maintenance +- Refactor continuously +- Fix technical debt early +- Leave code cleaner than you found it + +## Testing +- Write tests before fixing bugs +- Keep tests readable and maintainable +- Test edge cases and error conditions + +## Version Control +- Write clear commit messages +- Make small, focused commits +- Use meaningful branch names \ No newline at end of file diff --git a/.windsurf/rules/codequality.md b/.windsurf/rules/codequality.md new file mode 100644 index 0000000000..6ea7005e9a --- /dev/null +++ b/.windsurf/rules/codequality.md @@ -0,0 +1,45 @@ +--- +trigger: model_decision +description: Code Quality Guidelines +globs: +--- +# Code Quality Guidelines + +## Verify Information +Always verify information before presenting it. Do not make assumptions or speculate without clear evidence. + +## File-by-File Changes +Make changes file by file and give me a chance to spot mistakes. + +## No Apologies +Never use apologies. + +## No Understanding Feedback +Avoid giving feedback about understanding in comments or documentation. + +## No Whitespace Suggestions +Don't suggest whitespace changes. + +## No Inventions +Don't invent changes other than what's explicitly requested. + +## No Unnecessary Confirmations +Don't ask for confirmation of information already provided in the context. + +## Preserve Existing Code +Don't remove unrelated code or functionalities. Pay attention to preserving existing structures. + +## Single Chunk Edits +Provide all edits in a single chunk instead of multiple-step instructions or explanations for the same file. + +## No Implementation Checks +Don't ask the user to verify implementations that are visible in the provided context. + +## No Unnecessary Updates +Don't suggest updates or changes to files when there are no actual modifications needed. + +## Provide Real File Links +Always provide links to the real files, not x.md. + +## No Current Implementation +Don't show or discuss the current implementation unless specifically requested. diff --git a/.windsurf/rules/coding-guidelines---early-returns-and-conditionals.md b/.windsurf/rules/coding-guidelines---early-returns-and-conditionals.md new file mode 100644 index 0000000000..f8fbf96833 --- /dev/null +++ b/.windsurf/rules/coding-guidelines---early-returns-and-conditionals.md @@ -0,0 +1,7 @@ +--- +trigger: model_decision +description: Applies coding guidelines related to using early returns and conditional classes in all files. +globs: **/*.* +--- +- Utilize Early Returns: Use early returns to avoid nested conditions and improve readability. +- Conditional Classes: Prefer conditional classes over ternary operators for class attributes. \ No newline at end of file diff --git a/.windsurf/rules/comment-usage.md b/.windsurf/rules/comment-usage.md new file mode 100644 index 0000000000..8f369e92b4 --- /dev/null +++ b/.windsurf/rules/comment-usage.md @@ -0,0 +1,9 @@ +--- +trigger: model_decision +description: This rule dictates how comments should be used within the codebase to enhance understanding and avoid clutter. +globs: **/*.* +--- +- Use comments sparingly, and when you do, make them meaningful. +- Don't comment on obvious things. Excessive or unclear comments can clutter the codebase and become outdated. +- Use comments to convey the "why" behind specific actions or explain unusual behavior and potential pitfalls. +- Provide meaningful information about the function's behavior and explain unusual behavior and potential pitfalls. \ No newline at end of file diff --git a/.windsurf/rules/follow-up-questions.md b/.windsurf/rules/follow-up-questions.md new file mode 100644 index 0000000000..86587bca0d --- /dev/null +++ b/.windsurf/rules/follow-up-questions.md @@ -0,0 +1,5 @@ +--- +trigger: always_on +--- + +Do not make any changes, until you have 95% confidence that you know what to build ask me follow up questions until you have that confidence. \ No newline at end of file diff --git a/.windsurf/rules/function-length-and-responsibility.md b/.windsurf/rules/function-length-and-responsibility.md new file mode 100644 index 0000000000..beaabce1cf --- /dev/null +++ b/.windsurf/rules/function-length-and-responsibility.md @@ -0,0 +1,8 @@ +--- +trigger: model_decision +description: This rule enforces the single responsibility principle, ensuring functions are short and focused. +globs: **/*.* +--- +- Write short functions that only do one thing. +- Follow the single responsibility principle (SRP), which means that a function should have one purpose and perform it effectively. +- If a function becomes too long or complex, consider breaking it into smaller, more manageable functions. \ No newline at end of file diff --git a/.windsurf/rules/function-ordering-conventions.md b/.windsurf/rules/function-ordering-conventions.md new file mode 100644 index 0000000000..74a9b9ec1a --- /dev/null +++ b/.windsurf/rules/function-ordering-conventions.md @@ -0,0 +1,6 @@ +--- +trigger: model_decision +description: Defines the function ordering conventions, where functions that compose other functions appear earlier in the file, regardless of the file type. +globs: **/*.* +--- +- Order functions with those that are composing other functions appearing earlier in the file. For example, if you have a menu with multiple buttons, define the menu function above the buttons. \ No newline at end of file diff --git a/.windsurf/rules/general-code-commenting.md b/.windsurf/rules/general-code-commenting.md new file mode 100644 index 0000000000..ce8324b36c --- /dev/null +++ b/.windsurf/rules/general-code-commenting.md @@ -0,0 +1,7 @@ +--- +trigger: model_decision +description: Ensures helpful comments are added to the code and that old, relevant comments are preserved. +globs: **/*.* +--- +- Always add helpful comments to the code explaining what you are doing. +- Never delete old comments, unless they are no longer relevant because the code has been rewritten or deleted. \ No newline at end of file diff --git a/.windsurf/rules/general-coding-principles.md b/.windsurf/rules/general-coding-principles.md new file mode 100644 index 0000000000..12c6e25233 --- /dev/null +++ b/.windsurf/rules/general-coding-principles.md @@ -0,0 +1,8 @@ +--- +trigger: model_decision +description: Applies general coding principles like simplicity, readability, performance, maintainability, testability, and reusability to all files. +globs: **/*.* +--- +- Focus on simplicity, readability, performance, maintainability, testability, and reusability. +- Remember less code is better. +- Lines of code = Debt. \ No newline at end of file diff --git a/.windsurf/rules/minimal-code-changes-rule.md b/.windsurf/rules/minimal-code-changes-rule.md new file mode 100644 index 0000000000..8ffde9b2f9 --- /dev/null +++ b/.windsurf/rules/minimal-code-changes-rule.md @@ -0,0 +1,8 @@ +--- +trigger: model_decision +description: Enforces the principle of making minimal code changes to avoid introducing bugs or technical debt in any file. +globs: **/*.* +--- +- Only modify sections of the code related to the task at hand. +- Avoid modifying unrelated pieces of code. +- Accomplish goals with minimal code changes. \ No newline at end of file diff --git a/.windsurf/rules/naming-conventions.md b/.windsurf/rules/naming-conventions.md new file mode 100644 index 0000000000..ec2806fd50 --- /dev/null +++ b/.windsurf/rules/naming-conventions.md @@ -0,0 +1,8 @@ +--- +trigger: model_decision +description: This rule focuses on using meaningful and descriptive names for variables, functions, and classes throughout the project. +globs: **/*.* +--- +- Choose names for variables, functions, and classes that reflect their purpose and behavior. +- A name should tell you why it exists, what it does, and how it is used. If a name requires a comment, then the name does not reveal its intent. +- Use specific names that provide a clearer understanding of what the variables represent and how they are used. \ No newline at end of file diff --git a/.windsurf/rules/next-js-app-router-rule.md b/.windsurf/rules/next-js-app-router-rule.md new file mode 100644 index 0000000000..d1cc74f846 --- /dev/null +++ b/.windsurf/rules/next-js-app-router-rule.md @@ -0,0 +1,7 @@ +--- +trigger: model_decision +description: Applies Next.js App Router specific guidelines to components and pages within the 'app' directory. +globs: app/**/*.tsx +--- +- You are an expert in Next.js Pages Router. +- Follow Next.js documentation for best practices in data fetching, rendering, and routing. diff --git a/.windsurf/rules/next-js-configuration-rule.md b/.windsurf/rules/next-js-configuration-rule.md new file mode 100644 index 0000000000..4c80de1912 --- /dev/null +++ b/.windsurf/rules/next-js-configuration-rule.md @@ -0,0 +1,7 @@ +--- +trigger: model_decision +description: Rules specifically for the Next.js configuration file. +globs: /next.config.js +--- +- Ensure the Next.js configuration is optimized for performance. +- Review and update the configuration regularly based on project needs. \ No newline at end of file diff --git a/.windsurf/rules/next-js-conventions.md b/.windsurf/rules/next-js-conventions.md new file mode 100644 index 0000000000..0ac8b97925 --- /dev/null +++ b/.windsurf/rules/next-js-conventions.md @@ -0,0 +1,12 @@ +--- +trigger: model_decision +description: Key Next.js conventions for state changes, web vitals, and client-side code usage. +globs: **/*.{ts,js,jsx,tsx} +--- +- Rely on Next.js Pages Router for state changes. +- Prioritize Web Vitals (LCP, CLS, FID). +- Minimize 'use client' usage: + - Prefer server components and Next.js SSR features. + - Use 'use client' only for Web API access in small components. + - Avoid using 'use client' for data fetching or state management. + - Refer to Next.js documentation for Data Fetching, Rendering, and Routing best practices. diff --git a/.windsurf/rules/next-js-folder-structure.md b/.windsurf/rules/next-js-folder-structure.md new file mode 100644 index 0000000000..b83caacf04 --- /dev/null +++ b/.windsurf/rules/next-js-folder-structure.md @@ -0,0 +1,14 @@ +--- +trigger: model_decision +description: This rule defines the recommended folder structure for Next.js projects. +globs: app/**/*.* +--- +- Adhere to the following folder structure: + +src/ + components/ + lib/ + pages/ + hooks/ + utils/ +public/ diff --git a/.windsurf/rules/performance-optimization-rules.md b/.windsurf/rules/performance-optimization-rules.md new file mode 100644 index 0000000000..aa8d0799e7 --- /dev/null +++ b/.windsurf/rules/performance-optimization-rules.md @@ -0,0 +1,7 @@ +--- +trigger: model_decision +description: Guidelines for optimizing performance by minimizing client-side operations and using server-side rendering. +globs: **/*.{js,jsx,ts,tsx} +--- +- Optimize Web Vitals (LCP, CLS, FID). +- Use dynamic loading for non-critical components using @src/components/dls/Spinner/Spinner.tsx \ No newline at end of file diff --git a/.windsurf/rules/persona---senior-full-stack-developer.md b/.windsurf/rules/persona---senior-full-stack-developer.md new file mode 100644 index 0000000000..b13a6dbaea --- /dev/null +++ b/.windsurf/rules/persona---senior-full-stack-developer.md @@ -0,0 +1,6 @@ +--- +trigger: model_decision +description: Defines the persona as a senior full-stack developer with extensive knowledge applicable to all files. +globs: **/*.* +--- +- You are a senior full-stack developer. One of those rare 10x developers that has incredible knowledge. \ No newline at end of file diff --git a/.windsurf/rules/pre-pr-review.md b/.windsurf/rules/pre-pr-review.md new file mode 100644 index 0000000000..03eae49282 --- /dev/null +++ b/.windsurf/rules/pre-pr-review.md @@ -0,0 +1,57 @@ +# Pre-PR Code Review Standards + +When reviewing code changes, check against these Quran.com project guidelines: + +## TypeScript +- Flag any `any` types - use `unknown` or specific types +- Require explicit return types on exported functions +- Use interfaces for object shapes, types for unions +- Use enums for repeated categorical values + +## React Components +- Functional components only - no class components +- Props interface must be defined +- Use proper memoization (useCallback, useMemo) for callbacks/expensive computations +- Flag unnecessary useEffect - prefer event handlers or derived state +- Require skeleton loaders for async data to prevent layout shifts + +## Code Quality +- Functions should be under 30 lines +- Flag duplicated code - extract to reusable functions (DRY) +- Require proper error handling with meaningful fallbacks +- Flag unused imports, variables, or dead code +- Comments should explain "why" not "what" + +## API & Data Handling +- Use useSWR for data fetching, not raw fetch in components +- Handle error states with user-friendly messages +- Provide fallbacks for API responses - don't blindly trust data +- Use optimistic updates for predictable actions (bookmark, like) + +## Localization +- All user-facing text must use `t('key')` from next-translate +- No hardcoded strings in UI +- Use RTL-safe CSS logical properties (margin-inline-start, not margin-left) + +## Accessibility +- Use semantic HTML elements +- Add ARIA attributes where needed +- Ensure keyboard navigation works + +## Security +- No hardcoded secrets or credentials +- Use environment variables for configuration +- Validate and sanitize user inputs + +## Testing +- New functionality needs tests +- Cover edge cases and error scenarios +- Test file naming: `*.test.ts` or `*.test.tsx` + +## Review Output Format +When providing review feedback: +1. 🚨 **Critical** - Must fix before PR +2. 💡 **Suggestions** - Improvements to consider +3. ✅ **Good** - What's done well + +Include file paths and line numbers in feedback. diff --git a/.windsurf/rules/react-functional-components.md b/.windsurf/rules/react-functional-components.md new file mode 100644 index 0000000000..a4b65d8b08 --- /dev/null +++ b/.windsurf/rules/react-functional-components.md @@ -0,0 +1,7 @@ +--- +trigger: model_decision +description: Enforces the use of functional components with hooks in React components. +globs: src/components/**/*.tsx +--- +- Always use React functional components with hooks. +- Use React.FC for functional components with props. \ No newline at end of file diff --git a/.windsurf/rules/react.md b/.windsurf/rules/react.md new file mode 100644 index 0000000000..1db8bdfeab --- /dev/null +++ b/.windsurf/rules/react.md @@ -0,0 +1,76 @@ +--- +trigger: model_decision +description: React best practices and patterns for modern web applications +globs: **/*.tsx, **/*.jsx, components/**/* +--- + +# React Best Practices + +## Component Structure +- Use functional components over class components +- Keep components small and focused +- Extract reusable logic into custom hooks +- Use composition over inheritance +- Implement proper prop types with TypeScript +- Split large components into smaller, focused ones + +## Hooks +- Follow the Rules of Hooks +- Use custom hooks for reusable logic +- Keep hooks focused and simple +- Use appropriate dependency arrays in useEffect +- Implement cleanup in useEffect when needed +- Avoid nested hooks + +## State Management +- Use useState for local component state. +- Implement Redux Toolkit for efficient Redux development for medium-complex state logic. +- Implement slice pattern for organizing Redux code. +- Avoid prop drilling through proper state management. +- Use xstate for complex state logic. + +## Performance +- Implement proper memoization (useMemo, useCallback) +- Use React.memo for expensive components +- Avoid unnecessary re-renders +- Implement proper lazy loading +- Use proper key props in lists +- Profile and optimize render performance + +## Forms +- Re-use @src/components/FormBuilder/FormBuilder.tsx to build forms. +- Implement proper form validation. +- Handle form submission states properly. +- Show appropriate loading and error states. +- Implement proper accessibility for forms. + +## Error Handling +- Handle async errors properly +- Show user-friendly error messages +- Implement proper fallback UI +- Log errors appropriately +- Handle edge cases gracefully + +## Testing +- Write unit tests for components +- Implement integration tests for complex flows +- Use React Testing Library +- Test user interactions +- Test error scenarios +- Implement proper mock data + +## Accessibility +- Use semantic HTML elements +- Implement proper ARIA attributes +- Ensure keyboard navigation +- Test with screen readers +- Handle focus management +- Provide proper alt text for images + +## Code Organization +- Group related components together +- Use proper file naming conventions +- Implement proper directory structure +- Keep styles close to components +- Use proper imports/exports +- Document complex component logic diff --git a/.windsurf/rules/redux-folder-structure.md b/.windsurf/rules/redux-folder-structure.md new file mode 100644 index 0000000000..c4c9f4f8c7 --- /dev/null +++ b/.windsurf/rules/redux-folder-structure.md @@ -0,0 +1,13 @@ +--- +trigger: model_decision +description: Enforces specific folder structure conventions within the Redux store directory. +globs: src/redux/**/* +--- +- Follow this folder structure: + src/ + redux/ + actions/ + slices/ + RootState.ts + store.ts + migrations.ts \ No newline at end of file diff --git a/.windsurf/rules/redux-toolkit-best-practices.md b/.windsurf/rules/redux-toolkit-best-practices.md new file mode 100644 index 0000000000..93d4aafde2 --- /dev/null +++ b/.windsurf/rules/redux-toolkit-best-practices.md @@ -0,0 +1,10 @@ +--- +trigger: model_decision +description: Applies Redux Toolkit best practices for efficient Redux development. +globs: src/redux/**/*.ts +--- +- Use Redux Toolkit for efficient Redux development. +- Implement slice pattern for organizing Redux code. +- Use selectors for accessing state in components. +- Use Redux hooks (useSelector, useDispatch) in components. +- Follow Redux style guide for naming conventions \ No newline at end of file diff --git a/.windsurf/rules/typescript.md b/.windsurf/rules/typescript.md new file mode 100644 index 0000000000..33fd07ec31 --- /dev/null +++ b/.windsurf/rules/typescript.md @@ -0,0 +1,58 @@ +--- +trigger: model_decision +description: TypeScript coding standards and best practices for modern web development +globs: **/*.ts, **/*.tsx, **/*.d.ts +--- + +# TypeScript Best Practices + +## Type System +- Prefer interfaces over types for object definitions +- Use type for unions, intersections, and mapped types +- Avoid using `any`, prefer `unknown` for unknown types +- Use strict TypeScript configuration +- Leverage TypeScript's built-in utility types +- Use generics for reusable type patterns + +## Naming Conventions +- Use PascalCase for type names and interfaces +- Use camelCase for variables and functions +- Use UPPER_CASE for constants +- Use descriptive names with auxiliary verbs (e.g., isLoading, hasError) +- Prefix interfaces for React props with 'Props' (e.g., ButtonProps) + +## Code Organization +- Keep type definitions close to where they're used +- Export types and interfaces from dedicated type files when shared +- Use barrel exports (index.ts) for organizing exports +- Place shared types in a `types` directory +- Co-locate component props with their components + +## Functions +- Use explicit return types for public functions +- Use arrow functions for callbacks and methods +- Implement proper error handling with custom error types +- Use function overloads for complex type scenarios +- Prefer async/await over Promises + +## Best Practices +- Enable strict mode in tsconfig.json +- Use readonly for immutable properties +- Leverage discriminated unions for type safety +- Use type guards for runtime type checking +- Implement proper null checking +- Avoid type assertions unless necessary + +## Error Handling +- Create custom error types for domain-specific errors +- Use Result types for operations that can fail +- Implement proper error boundaries +- Use try-catch blocks with typed catch clauses +- Handle Promise rejections properly + +## Patterns +- Use the Builder pattern for complex object creation +- Implement the Repository pattern for data access +- Use the Factory pattern for object creation +- Leverage dependency injection +- Use the Module pattern for encapsulation \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..d4ec16ef5b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,191 @@ +# Agent Guidelines for quran.com-frontend-next + +You are an expert frontend engineer working on the Quran.com web application—a platform helping millions of Muslims engage with the Qur'an. + +## Commands (Run These) + +```bash +# Build & Development +yarn dev # Start dev server at localhost:3000 +yarn build # Production build (also runs type checking) + +# Testing +yarn test # Run all unit tests (Vitest) +yarn test # Run specific test file +yarn test:coverage # Run tests with coverage report +yarn test:integration # Run Playwright integration tests + +# Code Quality +yarn lint # Check ESLint errors +yarn lint:fix # Auto-fix ESLint errors +yarn lint:scss # Check SCSS lint errors + +# Validation (run before commits) +yarn lint && yarn build && yarn test +``` + +## Tech Stack + +- **Framework**: Next.js 14 (Pages Router, NOT App Router) +- **Language**: TypeScript 5 (strict mode enabled) +- **Styling**: SCSS Modules (component-scoped) +- **State**: Redux Toolkit, XState for complex flows +- **Data Fetching**: useSWR with caching +- **i18n**: next-translate (RTL support required) +- **UI Components**: Radix UI primitives +- **Testing**: Vitest (unit), Playwright (integration) + +## Project Structure + +```text +src/ +├── components/ # React components (READ/WRITE) +├── pages/ # Next.js pages (READ/WRITE) +├── hooks/ # Custom React hooks (READ/WRITE) +├── redux/ # Redux slices and store (READ/WRITE) +├── api.ts # API client functions (READ/WRITE) +├── utils/ # Utility functions (READ/WRITE) +├── styles/ # Global styles, theme (READ/WRITE) +├── types/ # TypeScript type definitions (READ/WRITE) +└── contexts/ # React contexts (READ/WRITE) +tests/ # Playwright tests (READ/WRITE) +locales/en/ # English translations only (READ/WRITE) +locales/*/ # Other languages - Lokalise managed (READ ONLY) +public/ # Static assets (READ ONLY usually) +``` + +## Code Style Examples + +```typescript +// ✅ Good - explicit types, proper error handling, descriptive names +interface VerseBookmarkProps { + verseKey: string; + isBookmarked: boolean; + onToggle: (verseKey: string) => Promise; +} + +const VerseBookmark: React.FC = ({ + verseKey, + isBookmarked, + onToggle +}) => { + const { t } = useTranslation('common'); + + const handleClick = useCallback(async () => { + try { + await onToggle(verseKey); + } catch (error) { + logError('Bookmark toggle failed', error); + toast.error(t('error.bookmark-failed')); + } + }, [verseKey, onToggle, t]); + + return ( + + ); +}; + +// ❌ Bad - any types, no error handling, hardcoded strings +const VerseBookmark = ({ verse, bookmarked, toggle }: any) => ( + +); +``` + +```typescript +// ✅ Good - useSWR for data fetching with error handling +const useVerses = (chapterId: number) => { + const { data, error, isLoading } = useSWR( + makeVersesUrl(chapterId), + fetcher + ); + + return { + verses: data?.verses ?? [], + isLoading, + error, + }; +}; + +// ❌ Bad - useEffect for fetching, no caching +const useVerses = (chapterId: number) => { + const [verses, setVerses] = useState([]); + useEffect(() => { + fetch(`/api/verses/${chapterId}`).then(r => r.json()).then(setVerses); + }, [chapterId]); + return verses; +}; +``` + +## Boundaries + +### ✅ Always Do + +- Run `yarn lint && yarn build` before committing +- Write TypeScript with explicit types (no `any`) +- Use `t('key')` for all user-facing text +- Add tests for new functionality +- Use SCSS modules for component styles +- Handle errors with meaningful fallbacks +- Use semantic HTML and ARIA attributes + +### ⚠️ Ask First + +- Adding new dependencies (check bundle size on Bundlephobia) +- Modifying Redux store structure +- Changing API response handling +- Database schema changes (backend) +- Modifying CI/CD configuration +- Major refactors across multiple files + +### 🚫 Never Do + +- Commit secrets, API keys, or credentials +- Use `any` type without justification +- Hardcode user-facing strings (use i18n) +- Modify `locales/` files other than `en/` +- Remove failing tests to make CI pass +- Use class components (functional only) +- Skip error handling for async operations +- Modify `node_modules/` or generated files + +## Git Workflow + +- Branch from `master` or `staging` +- PR target: `testing` branch +- Branch naming: `feature/xyz`, `fix/xyz`, `refactor/xyz` +- Commit messages: Clear, concise, purposeful +- PR title format: `[QF-XXX] Brief description` + +## Testing Standards + +```typescript +// ✅ Good test - descriptive, AAA pattern, proper assertions +describe('BookmarkButton', () => { + it('should call onBookmark with verse key when clicked', async () => { + // Arrange + const onBookmark = vi.fn(); + render(); + + // Act + await userEvent.click(screen.getByRole('button')); + + // Assert + expect(onBookmark).toHaveBeenCalledWith('1:1'); + }); +}); +``` + +## Additional Resources + +- **Code Review**: `.github/copilot-instructions.md` +- **Path-specific rules**: `.github/instructions/*.instructions.md` +- **Cursor rules**: `.cursor/rules/` +- **Windsurf rules**: `.windsurf/rules/` + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..5c52a685ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. + +## Common Development Commands + +### Development & Build + +- `yarn dev` - Start development server (default port 3000) +- `yarn dev:https` - Start HTTPS development server +- `yarn build` - Build production bundle +- `yarn start` - Start production server +- `yarn analyze` - Analyze bundle size + +### Testing + +- `yarn test` - Run unit tests with Vitest +- `yarn test:watch` - Run tests in watch mode +- `yarn test:integration` - Run Playwright integration tests +- `yarn test:coverage` - Run tests with coverage report + +### Code Quality + +- `yarn lint` - ESLint check +- `yarn lint:fix` - Fix ESLint issues +- `yarn lint:scss` - Lint SCSS files + +### Storybook + +- `yarn storybook` - Start Storybook dev server on port 6006 +- `yarn build-storybook` - Build Storybook + +## Architecture Overview + +### Framework & Stack + +- **Next.js 14** with Pages Router (not App Router) +- **TypeScript** with strict configuration +- **React 18** with functional components +- **Redux Toolkit** for complex state management with persistence +- **XState** for complex state machines (audio player, etc.) +- **SCSS Modules** for component styling +- **Vitest** for unit testing, **Playwright** for integration testing + +### Key Directories + +- `src/components/` - React components organized by feature +- `src/components/dls/` - Design Language System components +- `src/pages/` - Next.js pages (Pages Router) +- `src/redux/` - Redux store, slices, and middleware +- `src/utils/` - Utility functions organized by domain +- `src/hooks/` - Custom React hooks +- `types/` - TypeScript type definitions +- `locales/` - i18n translations (18+ languages) + +### State Management Architecture + +- **Redux Toolkit** with persistence for settings, preferences, bookmarks +- **XState** for complex stateful components (audio player) +- Local component state with `useState` for simple UI state +- Custom hooks for reusable stateful logic + +### Authentication & API + +- Custom auth system with JWT tokens and refresh tokens +- API utilities in `src/utils/auth/api.ts` +- Protected routes using `withAuth` HOC +- User preferences synced between client and server + +### Styling System + +- SCSS modules for component-specific styles +- Theme system supporting light, dark, and sepia themes +- Design tokens in `src/styles/_constants.scss` +- Responsive breakpoints in `src/styles/_breakpoints.scss` +- Path aliases: `@/` for `src/`, `@/dls/*` for design system components + +### Internationalization + +- **next-translate** for i18n with 18+ supported languages +- Translation files in `locales/[lang]/` directories +- RTL support for Arabic and other languages + +### Component Patterns + +- Functional components with TypeScript interfaces for props +- Custom hooks for reusable logic +- Storybook stories for component development +- Form building with `FormBuilder` component + +### Testing Strategy + +- Unit tests with Vitest and React Testing Library +- Integration tests with Playwright +- Component testing through Storybook +- Test files co-located with components + +### Build & Deployment + +- **Sentry** for error tracking and performance monitoring +- **PWA** support with service workers +- Bundle analysis and optimization + +## Important Notes + +- Uses Pages Router, not App Router +- Authentication is custom-built, not using next-auth +- State persistence uses redux-persist +- Multiple themes supported (light/dark/sepia) +- Extensive i18n support with RTL languages +- Audio functionality uses XState for complex state management diff --git a/README.md b/README.md index 7ce3dcc80e..1810992540 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ [![Stargazers][stars-shield]][stars-url] [![MIT License][license-shield]][license-url] -This project is the frontend for Quran.com. It is built on top of [Next.js](https://nextjs.org/docs/getting-started), a popular framework that takes the trouble and setup of setting up an isomorphic react app. We deploy it on now.sh automatically with automatic generation of builds for PRs. +This project is the frontend for Quran.com. It is built on top of [Next.js](https://nextjs.org/docs/getting-started), a popular framework that takes the trouble and setup of setting up an isomorphic react app. ### How to Contribute @@ -85,12 +85,23 @@ If you are interested to help out, please look at issues on the GitHub repo. Thi Thank you for taking time to file a bug! We'd appreciate your help on fixing it 🙏. Please [open an issue](https://github.com/quran/quran.com-frontend-next/issues). +### Pre-PR AI Code Review + +Before opening a PR, run an AI code review to catch issues early. + +| IDE | Command | +|-----|---------| +| **VS Code** | Open Chat → Type `/review` | +| **Cursor** | Open Chat (Cmd+L) → Type `/review` or use Command Palette → "Run Task" → "Pre-PR Review" | +| **Windsurf** | Open Cascade → Type `/review` or use Command Palette → "Run Task" → "Pre-PR Review" | + +All IDEs also support: **Command Palette** (`Cmd+Shift+P`) → **Tasks: Run Task** → **Pre-PR Review** + ### Community Join Quran.com Discord community » - [contributors-shield]: https://img.shields.io/github/contributors/quran/quran.com-frontend-next?style=for-the-badge [contributors-url]: https://github.com/quran/quran.com-frontend-next/graphs/contributors diff --git a/data/ayah_of_the_day.json b/data/ayah_of_the_day.json index b61ac2e53e..37670087f2 100644 --- a/data/ayah_of_the_day.json +++ b/data/ayah_of_the_day.json @@ -1,4 +1,340 @@ [ + { + "date": "25/03/2025", + "verseKey": "39:53" + }, + { + "date": "26/03/2025", + "verseKey": "9:124" + }, + { + "date": "27/03/2025", + "verseKey": "67:30" + }, + { + "date": "28/03/2025", + "verseKey": "47:31" + }, + { + "date": "29/03/2025", + "verseKey": "25:71" + }, + { + "date": "30/03/2025", + "verseKey": "57:6" + }, + { + "date": "31/03/2025", + "verseKey": "38:29" + }, + { + "date": "01/04/2025", + "verseKey": "57:16" + }, + { + "date": "02/04/2025", + "verseKey": "59:23" + }, + { + "date": "03/04/2025", + "verseKey": "63:9" + }, + { + "date": "04/04/2025", + "verseKey": "1:2" + }, + { + "date": "05/04/2025", + "verseKey": "2:2" + }, + { + "date": "06/04/2025", + "verseKey": "2:21" + }, + { + "date": "07/04/2025", + "verseKey": "2:32" + }, + { + "date": "08/04/2025", + "verseKey": "2:44" + }, + { + "date": "09/04/2025", + "verseKey": "2:45" + }, + { + "date": "10/04/2025", + "verseKey": "2:63" + }, + { + "date": "11/04/2025", + "verseKey": "2:81" + }, + { + "date": "12/04/2025", + "verseKey": "2:109" + }, + { + "date": "13/04/2025", + "verseKey": "2:112" + }, + { + "date": "14/04/2025", + "verseKey": "2:124" + }, + { + "date": "15/04/2025", + "verseKey": "2:131" + }, + { + "date": "16/04/2025", + "verseKey": "2:138" + }, + { + "date": "17/04/2025", + "verseKey": "2:155" + }, + { + "date": "18/04/2025", + "verseKey": "2:163" + }, + { + "date": "19/04/2025", + "verseKey": "2:172" + }, + { + "date": "20/04/2025", + "verseKey": "2:186" + }, + { + "date": "21/04/2025", + "verseKey": "2:195" + }, + { + "date": "22/04/2025", + "verseKey": "2:201" + }, + { + "date": "23/04/2025", + "verseKey": "2:214" + }, + { + "date": "24/04/2025", + "verseKey": "2:216" + }, + { + "date": "25/04/2025", + "verseKey": "2:237" + }, + { + "date": "26/04/2025", + "verseKey": "2:245" + }, + { + "date": "27/04/2025", + "verseKey": "2:256" + }, + { + "date": "28/04/2025", + "verseKey": "2:261" + }, + { + "date": "29/04/2025", + "verseKey": "2:267" + }, + { + "date": "30/04/2025", + "verseKey": "2:276" + }, + { + "date": "01/05/2025", + "verseKey": "2:281" + }, + { + "date": "02/05/2025", + "verseKey": "3:8" + }, + { + "date": "03/05/2025", + "verseKey": "3:26" + }, + { + "date": "04/05/2025", + "verseKey": "3:31" + }, + { + "date": "05/05/2025", + "verseKey": "3:59" + }, + { + "date": "06/05/2025", + "verseKey": "3:67" + }, + { + "date": "07/05/2025", + "verseKey": "3:83" + }, + { + "date": "08/05/2025", + "verseKey": "3:102" + }, + { + "date": "09/05/2025", + "verseKey": "3:113" + }, + { + "date": "10/05/2025", + "verseKey": "3:133" + }, + { + "date": "11/05/2025", + "verseKey": "3:139" + }, + { + "date": "12/05/2025", + "verseKey": "3:159" + }, + { + "date": "13/05/2025", + "verseKey": "3:169" + }, + { + "date": "14/05/2025", + "verseKey": "3:185" + }, + { + "date": "15/05/2025", + "verseKey": "3:194" + }, + { + "date": "16/05/2025", + "verseKey": "4:7" + }, + { + "date": "17/05/2025", + "verseKey": "4:10" + }, + { + "date": "18/05/2025", + "verseKey": "4:17" + }, + { + "date": "19/05/2025", + "verseKey": "4:27" + }, + { + "date": "20/05/2025", + "verseKey": "4:31" + }, + { + "date": "21/05/2025", + "verseKey": "4:42" + }, + { + "date": "22/05/2025", + "verseKey": "4:45" + }, + { + "date": "23/05/2025", + "verseKey": "4:58" + }, + { + "date": "24/05/2025", + "verseKey": "4:69" + }, + { + "date": "25/05/2025", + "verseKey": "4:80" + }, + { + "date": "26/05/2025", + "verseKey": "4:87" + }, + { + "date": "27/05/2025", + "verseKey": "4:103" + }, + { + "date": "28/05/2025", + "verseKey": "4:106" + }, + { + "date": "29/05/2025", + "verseKey": "4:110" + }, + { + "date": "30/05/2025", + "verseKey": "4:123" + }, + { + "date": "31/05/2025", + "verseKey": "4:132" + }, + { + "date": "01/06/2025", + "verseKey": "4:134" + }, + { + "date": "02/06/2025", + "verseKey": "4:147" + }, + { + "date": "03/06/2025", + "verseKey": "4:166" + }, + { + "date": "04/06/2025", + "verseKey": "4:170" + }, + { + "date": "05/06/2025", + "verseKey": "4:174" + }, + { + "date": "06/06/2025", + "verseKey": "5:1" + }, + { + "date": "07/06/2025", + "verseKey": "5:7" + }, + { + "date": "08/06/2025", + "verseKey": "5:9" + }, + { + "date": "09/06/2025", + "verseKey": "5:11" + }, + { + "date": "10/06/2025", + "verseKey": "5:35" + }, + { + "date": "11/06/2025", + "verseKey": "5:39" + }, + { + "date": "12/06/2025", + "verseKey": "5:40" + }, + { + "date": "13/06/2025", + "verseKey": "5:55" + }, + { + "date": "14/06/2025", + "verseKey": "5:65" + }, + { + "date": "15/06/2025", + "verseKey": "5:78" + }, + { + "date": "16/06/2025", + "verseKey": "5:87" + }, { "date": "17/06/2025", "verseKey": "5:98" diff --git a/data/quranic-calendar.json b/data/quranic-calendar.json index 58770a37cf..97761419c3 100644 --- a/data/quranic-calendar.json +++ b/data/quranic-calendar.json @@ -6,7 +6,7 @@ "hijriMonth": "10", "year": "2025", "month": "4", - "day": "1", + "day": "4", "ranges": "1:1-2:74" }, { @@ -15,7 +15,7 @@ "hijriMonth": "10", "year": "2025", "month": "4", - "day": "8", + "day": "11", "ranges": "2:75-2:157" }, { @@ -24,7 +24,7 @@ "hijriMonth": "10", "year": "2025", "month": "4", - "day": "15", + "day": "18", "ranges": "2:158-2:230" }, { @@ -33,7 +33,7 @@ "hijriMonth": "10", "year": "2025", "month": "4", - "day": "22", + "day": "25", "ranges": "2:231-2:286" }, { @@ -41,8 +41,8 @@ "hijriYear": "1446", "hijriMonth": "10", "year": "2025", - "month": "4", - "day": "29", + "month": "5", + "day": "2", "ranges": "3:1-3:109" } ], @@ -53,7 +53,7 @@ "hijriMonth": "11", "year": "2025", "month": "5", - "day": "7", + "day": "9", "ranges": "3:110-3:200" }, { @@ -62,7 +62,7 @@ "hijriMonth": "11", "year": "2025", "month": "5", - "day": "14", + "day": "16", "ranges": "4:1-4:57" }, { @@ -71,7 +71,7 @@ "hijriMonth": "11", "year": "2025", "month": "5", - "day": "21", + "day": "23", "ranges": "4:58-4:115" }, { @@ -80,7 +80,7 @@ "hijriMonth": "11", "year": "2025", "month": "5", - "day": "28", + "day": "30", "ranges": "4:116-4:176" } ], @@ -91,7 +91,7 @@ "hijriMonth": "12", "year": "2025", "month": "6", - "day": "5", + "day": "6", "ranges": "5:1-5:50" }, { @@ -100,7 +100,7 @@ "hijriMonth": "12", "year": "2025", "month": "6", - "day": "12", + "day": "13", "ranges": "5:51-5:120" }, { @@ -109,7 +109,7 @@ "hijriMonth": "12", "year": "2025", "month": "6", - "day": "19", + "day": "20", "ranges": "6:1-6:94" }, { @@ -118,7 +118,7 @@ "hijriMonth": "12", "year": "2025", "month": "6", - "day": "26", + "day": "27", "ranges": "6:95-6:165" } ], @@ -167,7 +167,7 @@ "hijriMonth": "1", "year": "2025", "month": "8", - "day": "2", + "day": "1", "ranges": "9:60-9:129" }, { @@ -176,7 +176,7 @@ "hijriMonth": "2", "year": "2025", "month": "8", - "day": "9", + "day": "8", "ranges": "10:1-10:109" }, { @@ -185,7 +185,7 @@ "hijriMonth": "2", "year": "2025", "month": "8", - "day": "16", + "day": "15", "ranges": "11:1-11:123" }, { @@ -194,7 +194,7 @@ "hijriMonth": "2", "year": "2025", "month": "8", - "day": "23", + "day": "22", "ranges": "12:1-12:111" } ], @@ -204,8 +204,8 @@ "hijriYear": "1447", "hijriMonth": "2", "year": "2025", - "month": "9", - "day": "1", + "month": "8", + "day": "29", "ranges": "13:1-14:52" }, { @@ -214,7 +214,7 @@ "hijriMonth": "3", "year": "2025", "month": "9", - "day": "8", + "day": "5", "ranges": "15:1-16:89" }, { @@ -223,7 +223,7 @@ "hijriMonth": "3", "year": "2025", "month": "9", - "day": "15", + "day": "12", "ranges": "16:90-17:111" }, { @@ -232,7 +232,7 @@ "hijriMonth": "3", "year": "2025", "month": "9", - "day": "22", + "day": "19", "ranges": "18:1-18:110" }, { @@ -241,7 +241,7 @@ "hijriMonth": "3", "year": "2025", "month": "9", - "day": "29", + "day": "26", "ranges": "19:1-20:135" } ], @@ -252,7 +252,7 @@ "hijriMonth": "3", "year": "2025", "month": "10", - "day": "6", + "day": "3", "ranges": "21:1-21:112" }, { @@ -261,7 +261,7 @@ "hijriMonth": "4", "year": "2025", "month": "10", - "day": "13", + "day": "10", "ranges": "22:1-23:118" }, { @@ -270,7 +270,7 @@ "hijriMonth": "4", "year": "2025", "month": "10", - "day": "20", + "day": "17", "ranges": "24:1-24:64" }, { @@ -279,7 +279,7 @@ "hijriMonth": "4", "year": "2025", "month": "10", - "day": "27", + "day": "24", "ranges": "25:1-26:227" } ], @@ -289,8 +289,8 @@ "hijriYear": "1447", "hijriMonth": "4", "year": "2025", - "month": "11", - "day": "5", + "month": "10", + "day": "31", "ranges": "27:1-28:50" }, { @@ -299,7 +299,7 @@ "hijriMonth": "5", "year": "2025", "month": "11", - "day": "12", + "day": "7", "ranges": "28:51-29:69" }, { @@ -308,7 +308,7 @@ "hijriMonth": "5", "year": "2025", "month": "11", - "day": "19", + "day": "14", "ranges": "30:1-32:30" }, { @@ -317,7 +317,7 @@ "hijriMonth": "5", "year": "2025", "month": "11", - "day": "26", + "day": "21", "ranges": "33:1-34:30" } ], @@ -327,8 +327,8 @@ "hijriYear": "1447", "hijriMonth": "5", "year": "2025", - "month": "12", - "day": "3", + "month": "11", + "day": "28", "ranges": "34:31-36:83" }, { @@ -337,7 +337,7 @@ "hijriMonth": "6", "year": "2025", "month": "12", - "day": "10", + "day": "5", "ranges": "37:1-38:88" }, { @@ -346,7 +346,7 @@ "hijriMonth": "6", "year": "2025", "month": "12", - "day": "17", + "day": "12", "ranges": "39:1-40:85" }, { @@ -355,7 +355,7 @@ "hijriMonth": "6", "year": "2025", "month": "12", - "day": "24", + "day": "19", "ranges": "41:1-42:53" } ], @@ -364,9 +364,9 @@ "weekNumber": "39", "hijriYear": "1447", "hijriMonth": "6", - "year": "2026", - "month": "1", - "day": "2", + "year": "2025", + "month": "12", + "day": "26", "ranges": "43:1-45:37" }, { @@ -375,7 +375,7 @@ "hijriMonth": "6", "year": "2026", "month": "1", - "day": "9", + "day": "2", "ranges": "46:1-49:18" }, { @@ -384,7 +384,7 @@ "hijriMonth": "7", "year": "2026", "month": "1", - "day": "16", + "day": "9", "ranges": "50:1-54:55" }, { @@ -393,7 +393,7 @@ "hijriMonth": "7", "year": "2026", "month": "1", - "day": "23", + "day": "16", "ranges": "55:1-58:22" }, { @@ -402,7 +402,7 @@ "hijriMonth": "7", "year": "2026", "month": "1", - "day": "30", + "day": "23", "ranges": "59:1-66:12" } ], @@ -412,8 +412,8 @@ "hijriYear": "1447", "hijriMonth": "7", "year": "2026", - "month": "2", - "day": "7", + "month": "1", + "day": "30", "ranges": "67:1-74:56" }, { @@ -422,7 +422,7 @@ "hijriMonth": "8", "year": "2026", "month": "2", - "day": "14", + "day": "6", "ranges": "75:1-83:36" }, { @@ -431,7 +431,7 @@ "hijriMonth": "8", "year": "2026", "month": "2", - "day": "21", + "day": "13", "ranges": "84:1-114:6" } ] diff --git a/docs/BOOKMARKS_AND_COLLECTIONS.md b/docs/BOOKMARKS_AND_COLLECTIONS.md new file mode 100644 index 0000000000..5b7a51d297 --- /dev/null +++ b/docs/BOOKMARKS_AND_COLLECTIONS.md @@ -0,0 +1,735 @@ +# Bookmarks and Collections System + +This document provides comprehensive documentation for the bookmarks and collections feature in +quran.com-frontend-next. + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Data Structures](#data-structures) +4. [Hooks](#hooks) +5. [Redux State](#redux-state) +6. [API Layer](#api-layer) +7. [UI Components](#ui-components) +8. [Collections Feature](#collections-feature) +9. [Sync Logic](#sync-logic) +10. [Caching Strategy](#caching-strategy) + +--- + +## Overview + +The bookmarks system allows users to save and organize Quran verses and pages for later reference. +Key features include: + +- **Verse Bookmarks**: Save individual ayahs (verses) +- **Page Bookmarks**: Save entire pages +- **Collections**: Organize bookmarks into named groups +- **Dual Storage**: Local storage for anonymous users, server storage for logged-in users +- **Sync**: One-time sync of local bookmarks when user signs up +- **Mushaf-Aware**: Bookmarks are tied to specific mushaf (Quran edition/font) + +--- + +## Architecture + +### Storage Strategy + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User State │ +├─────────────────────────────┬───────────────────────────────────┤ +│ Anonymous User │ Logged-In User │ +├─────────────────────────────┼───────────────────────────────────┤ +│ Redux Store │ Server API │ +│ ├─ bookmarkedVerses │ ├─ /bookmarks │ +│ └─ bookmarkedPages │ ├─ /collections │ +│ │ └─ /collections/{id}/bookmarks │ +│ Persisted via │ │ +│ redux-persist │ Cached via SWR │ +│ (localStorage) │ │ +└─────────────────────────────┴───────────────────────────────────┘ +``` + +### Data Flow + +``` +User Action (Add/Remove Bookmark) + │ + ▼ + ┌────────────┐ + │ Is Logged │ + │ In? │ + └─────┬──────┘ + │ + ┌─────┴─────┐ + │ │ + ▼ ▼ + Yes No + │ │ + ▼ ▼ + API Call Redux + (POST/ Dispatch + DELETE) (toggle) + │ │ + ▼ ▼ + SWR Cache Redux + Mutate Store + │ │ + └─────┬─────┘ + │ + ▼ + UI Re-renders +``` + +--- + +## Data Structures + +### Core Types + +#### Bookmark (`types/Bookmark.ts`) + +```typescript +interface Bookmark { + id: string; // Unique identifier (server-generated) + key: number; // Chapter number (for verses) or page number + type: string; // BookmarkType enum value + verseNumber?: number; // Verse number (only for Ayah bookmarks) +} +``` + +#### BookmarkType (`types/BookmarkType.ts`) + +```typescript +enum BookmarkType { + Page = 'page', + Juz = 'juz', + Surah = 'surah', + Ayah = 'ayah', +} +``` + +#### BookmarksMap (`types/BookmarksMap.ts`) + +```typescript +// Maps verse keys (e.g., "1:5") to Bookmark objects +// Used for bulk fetching bookmarks in a range +type BookmarksMap = Record; +``` + +#### Collection (`types/Collection.ts`) + +```typescript +type Collection = { + id: string; // Unique identifier + updatedAt: string; // ISO timestamp + name: string; // Display name + url: string; // Slugified ID for URL routing +}; +``` + +#### Sort Options (`types/CollectionSortOptions.ts`) + +```typescript +// For sorting bookmarks within a collection +enum CollectionDetailSortOption { + RecentlyAdded = 'recentlyAdded', + VerseKey = 'verseKey', // Mushaf order +} + +// For sorting collections list +enum CollectionListSortOption { + RecentlyUpdated = 'recentlyUpdated', + Alphabetical = 'alphabetical', +} +``` + +--- + +## Hooks + +### useVerseBookmark + +**File:** `src/hooks/useVerseBookmark.ts` + +Handles verse (Ayah) bookmark operations with support for both logged-in and anonymous users. + +```typescript +interface UseVerseBookmarkProps { + verse: WordVerse; + mushafId: number; + bookmarksRangeUrl?: string; // Optional URL for bulk-fetched bookmarks +} + +interface UseVerseBookmarkReturn { + isVerseBookmarked: boolean; + isLoading: boolean; + handleToggleBookmark: () => void; +} +``` + +**Features:** + +- Optimistic updates for instant UI feedback +- SWR cache invalidation after changes +- Toast notifications for user feedback +- Supports bulk bookmark fetching via `bookmarksRangeUrl` + +**Usage:** + +```tsx +const { isVerseBookmarked, isLoading, handleToggleBookmark } = useVerseBookmark({ + verse, + mushafId, + bookmarksRangeUrl, // Pass this when using bulk fetching +}); +``` + +### usePageBookmark + +**File:** `src/hooks/usePageBookmark.ts` + +Handles page bookmark operations. + +```typescript +interface UsePageBookmarkProps { + pageNumber: number; + mushafId: number; +} + +interface UsePageBookmarkReturn { + isPageBookmarked: boolean; + isLoading: boolean; + handleToggleBookmark: () => void; +} +``` + +**Features:** + +- Single page bookmark fetch via SWR +- Optimistic updates with error recovery +- Handles both logged-in and anonymous users + +**Usage:** + +```tsx +const { isPageBookmarked, isLoading, handleToggleBookmark } = usePageBookmark({ + pageNumber: 5, + mushafId, +}); +``` + +### useSyncUserData + +**File:** `src/hooks/auth/useSyncUserData.ts` + +One-time sync of local bookmarks to server when user signs up. + +**Sync Flow:** + +1. Check `getLastSyncAt()` - if null, sync needed +2. Convert Redux bookmarks to API format +3. POST to `/users/syncLocalData` +4. Set sync timestamp +5. Invalidate SWR caches + +**Data Conversion:** + +```typescript +// Local verse bookmark: { "1:5": 1699999999999 } +// Converted to: +{ + createdAt: "2023-11-14T12:00:00.000Z", + type: "ayah", + key: 1, // Chapter number + verseNumber: 5, + mushaf: 1, +} +``` + +--- + +## Redux State + +### Bookmarks Slice + +**File:** `src/redux/slices/QuranReader/bookmarks.ts` + +```typescript +type Bookmarks = { + bookmarkedVerses: Record; // verseKey -> timestamp + bookmarkedPages: Record; // pageNumber -> timestamp +}; +``` + +### Reducers + +```typescript +// Toggle a verse bookmark (add if missing, remove if exists) +toggleVerseBookmark(state, action: PayloadAction) + +// Toggle a page bookmark +togglePageBookmark(state, action: PayloadAction) +``` + +### Selectors + +```typescript +// Get bookmarked verses (insertion order - newest first) +selectBookmarks(state): Record + +// Get bookmarked pages +selectBookmarkedPages(state): Record + +// Get verses sorted by verse key (Mushaf order) +selectOrderedBookmarkedVerses(state): Record + +// Get pages sorted by page number +selectOrderedBookmarkedPages(state): Record +``` + +**Usage:** + +```tsx +import { useSelector, useDispatch } from 'react-redux'; +import { selectBookmarks, toggleVerseBookmark } from '@/redux/slices/QuranReader/bookmarks'; + +const bookmarks = useSelector(selectBookmarks); +const dispatch = useDispatch(); + +// Toggle bookmark +dispatch(toggleVerseBookmark('1:5')); +``` + +--- + +## API Layer + +### API Functions + +**File:** `src/utils/auth/api.ts` + +#### Bookmark Operations + +```typescript +// Add a new bookmark +addBookmark({ key, mushafId, type, verseNumber }): Promise + +// Get a single bookmark +getBookmark(mushafId, key, type, verseNumber?): Promise + +// Get bookmarks in a range (bulk fetch) +getPageBookmarks(mushafId, chapterNumber, verseNumber, perPage): Promise + +// Delete a bookmark +deleteBookmarkById(bookmarkId): Promise + +// Get collections containing a bookmark +getBookmarkCollections(mushafId, key, type, verseNumber?): Promise +``` + +#### Collection Operations + +```typescript +// Get all user's collections +getCollectionsList(queryParams): Promise<{ data: Collection[] }> + +// Get bookmarks in a collection +getBookmarksByCollectionId(collectionId, queryParams): Promise + +// Create a new collection +addCollection(collectionName): Promise + +// Rename a collection +updateCollection(collectionId, { name }): Promise + +// Delete a collection +deleteCollection(collectionId): Promise + +// Add bookmark to collection +addCollectionBookmark({ collectionId, key, mushaf, type, verseNumber }): Promise + +// Remove bookmark from collection by ID +deleteCollectionBookmarkById(collectionId, bookmarkId): Promise + +// Remove bookmark from collection by key +deleteCollectionBookmarkByKey({ collectionId, key, mushaf, type, verseNumber }): Promise + +// Sync local data to server +syncUserLocalData(payload): Promise +``` + +### API Path Builders + +**File:** `src/utils/auth/apiPaths.ts` + +```typescript +// Bookmark URLs +makeBookmarksUrl(mushafId, limit?) // GET /bookmarks?mushafId=X&limit=Y +makeBookmarkUrl(mushafId, key, type, verseNumber?) // GET /bookmarks/bookmark?... +makeBookmarksRangeUrl(mushafId, chapter, verse, perPage) // GET /bookmarks/ayahs-range?... +makeBookmarkCollectionsUrl(mushafId, key, type, verseNumber?) // GET /bookmarks/collections?... +makeDeleteBookmarkUrl(bookmarkId) // DELETE /bookmarks/{id} + +// Collection URLs +makeCollectionsUrl(queryParams) // GET /collections?... +makeGetBookmarkByCollectionId(collectionId, queryParams) // GET /collections/{id}?... +makeAllCollectionsItemsUrl(queryParams) // GET /collections/all?... +makeAddCollectionUrl() // POST /collections +makeUpdateCollectionUrl(collectionId) // POST /collections/{id} +makeDeleteCollectionUrl(collectionId) // DELETE /collections/{id} +makeAddCollectionBookmarkUrl(collectionId) // POST /collections/{id}/bookmarks +makeDeleteCollectionBookmarkByIdUrl(collectionId, bookmarkId) // DELETE /collections/{id}/bookmarks/{bookmarkId} +makeDeleteCollectionBookmarkByKeyUrl(collectionId) // DELETE /collections/{id}/bookmarks +``` + +--- + +## UI Components + +### Bookmark Components + +#### BookmarkIcon + +**File:** `src/components/QuranReader/TranslationView/BookmarkIcon.tsx` + +Displays bookmark icon in translation view. Shows only when verse is bookmarked. + +```tsx + +``` + +#### PageBookmarkAction + +**File:** `src/components/QuranReader/ContextMenu/components/PageBookmarkAction.tsx` + +Context menu action for bookmarking/unbookmarking pages. + +```tsx + +``` + +#### SaveToCollectionAction + +**File:** `src/components/Verse/SaveToCollectionAction.tsx` + +Menu item to save a verse to collections. Opens modal for collection management. + +```tsx + +``` + +### Collection Components + +#### SaveToCollectionModal + +**File:** `src/components/Collection/SaveToCollectionModal/SaveToCollectionModal.tsx` + +Modal for adding/removing bookmarks from collections. + +```tsx + +``` + +#### CollectionList + +**File:** `src/components/Collection/CollectionList/CollectionList.tsx` + +Displays all user's collections with sort options and management actions. + +#### CollectionDetailContainer + +**File:** `src/components/Collection/CollectionDetailContainer/CollectionDetailContainer.tsx` + +Displays bookmarks within a collection with infinite scroll pagination. + +#### BookmarkedVersesList + +**File:** `src/components/Verses/BookmarkedVersesList.tsx` + +Shows recent bookmarks (limit: 10) for quick access. + +#### BookmarksAndCollectionsSection + +**File:** `src/components/Verses/BookmarksAndCollectionsSection.tsx` + +Tab-based section combining: + +- Recently Read Sessions +- Bookmarks +- Collections (logged-in only) + +--- + +## Collections Feature + +### Hierarchy + +``` +Collections List (/collections) + │ + ├── Collection A + │ ├── Bookmark 1 + │ ├── Bookmark 2 + │ └── Bookmark 3 + │ + ├── Collection B + │ ├── Bookmark 2 (same bookmark can be in multiple collections) + │ └── Bookmark 4 + │ + └── All Saved Verses (/collections/all) (aggregated view) + ├── Bookmark 1 + ├── Bookmark 2 + ├── Bookmark 3 + └── Bookmark 4 +``` + +### Collection Pages + +#### All Collections Items + +**File:** `src/pages/collections/all/index.tsx` + +- Shows all bookmarks across all collections +- Cursor-based pagination with SWR Infinite +- Sort by Recently Added or Verse Key +- Protected route (requires authentication) + +#### Collection Detail + +**File:** `src/pages/collections/[collectionId]/index.tsx` + +- Shows bookmarks in a specific collection +- Cursor-based pagination +- Owner can rename/delete collection +- Owner can remove bookmarks from collection + +### Collection Operations Flow + +``` +Create Collection Add to Collection Remove from Collection + │ │ │ + ▼ ▼ ▼ +POST /collections POST /collections/{id}/ DELETE /collections/{id}/ + │ bookmarks bookmarks/{bookmarkId} + ▼ │ │ +Invalidate Invalidate Invalidate +collections cache bookmark caches bookmark caches +``` + +--- + +## Sync Logic + +### One-Time Sync on Signup + +When a user signs up, their local (anonymous) bookmarks are synced to the server once. + +**Flow:** + +``` +User Signs Up + │ + ▼ +Complete Signup Page + │ + ▼ +useSyncUserData Hook + │ + ▼ +Check getLastSyncAt() + │ + ├─── Has value ──► Skip sync + │ + └─── null ──► Perform sync + │ + ▼ + Convert local bookmarks + to API format + │ + ▼ + POST /users/syncLocalData + │ + ▼ + Set lastSyncAt timestamp + │ + ▼ + Invalidate SWR caches +``` + +### Data Conversion + +```typescript +// Local Redux format +{ + bookmarkedVerses: { + "1:5": 1699999999999, // verseKey: timestamp + "2:255": 1699999999998, + }, + bookmarkedPages: { + "5": 1699999999997, // pageNumber: timestamp + } +} + +// API sync format +{ + bookmarks: [ + { + createdAt: "2023-11-14T12:00:00.000Z", + type: "ayah", + key: 1, + verseNumber: 5, + mushaf: 1, + }, + { + createdAt: "2023-11-14T11:59:59.998Z", + type: "ayah", + key: 2, + verseNumber: 255, + mushaf: 1, + }, + { + createdAt: "2023-11-14T11:59:59.997Z", + type: "page", + key: 5, + mushaf: 1, + }, + ] +} +``` + +--- + +## Caching Strategy + +### SWR Cache Keys + +The system uses multiple SWR cache keys for different granularities: + +```typescript +// Single bookmark check +makeBookmarkUrl(mushafId, key, type, verseNumber?) +// Example: /bookmarks/bookmark?mushafId=1&key=1&type=ayah&verseNumber=5 + +// Bulk bookmark fetch (range) +makeBookmarksRangeUrl(mushafId, chapterNumber, verseNumber, perPage) +// Example: /bookmarks/ayahs-range?mushafId=1&chapterNumber=1&verseNumber=1&perPage=50 + +// All bookmarks list +makeBookmarksUrl(mushafId, limit?) +// Example: /bookmarks?mushafId=1&limit=10 + +// Collections containing a bookmark +makeBookmarkCollectionsUrl(mushafId, key, type, verseNumber?) +// Example: /bookmarks/collections?mushafId=1&key=1&type=ayah&verseNumber=5 + +// Collections list +makeCollectionsUrl(queryParams) +// Example: /collections?type=ayah&sortBy=recentlyUpdated +``` + +### Cache Invalidation + +After bookmark operations, multiple caches need invalidation: + +```typescript +import { mutate as globalMutate } from 'swr'; + +// After adding/removing a bookmark: +globalMutate(makeBookmarksUrl(mushafId)); // Invalidate list +globalMutate(makeBookmarkUrl(...)); // Invalidate single +globalMutate(bookmarksRangeUrl); // Invalidate range +globalMutate(makeBookmarkCollectionsUrl(...)); // Invalidate collections for bookmark +globalMutate(makeCollectionsUrl(...)); // Invalidate collections list +``` + +### Optimistic Updates + +For instant UI feedback, optimistic updates are used: + +```typescript +// Example from useVerseBookmark.ts +const handleRemoveBookmark = async () => { + const bookmark = getBookmarkFromCache(); + if (!bookmark) return; + + // Optimistic update - remove from cache immediately + updateBookmarkCaches(null); // Set to null/empty + + try { + await deleteBookmarkById(bookmark.id); + // Invalidate other caches + globalMutate(makeBookmarksUrl(mushafId)); + } catch (error) { + // Revert on error + updateBookmarkCaches(bookmark); + showToast(t('error-removing-bookmark')); + } +}; +``` + +--- + +## File Reference + +### Types + +| File | Purpose | +| ------------------------------------------ | ------------------- | +| `types/Bookmark.ts` | Bookmark interface | +| `types/BookmarksMap.ts` | Bulk bookmarks type | +| `types/BookmarkType.ts` | Bookmark type enum | +| `types/Collection.ts` | Collection type | +| `types/CollectionSortOptions.ts` | Sort enums | +| `types/auth/SyncDataType.ts` | Sync data type enum | +| `types/auth/GetBookmarksByCollectionId.ts` | API response type | + +### Hooks + +| File | Purpose | +| ----------------------------------- | ------------------------- | +| `src/hooks/useVerseBookmark.ts` | Verse bookmark operations | +| `src/hooks/usePageBookmark.ts` | Page bookmark operations | +| `src/hooks/auth/useSyncUserData.ts` | One-time sync on signup | + +### Redux + +| File | Purpose | +| ------------------------------------------- | --------------------- | +| `src/redux/slices/QuranReader/bookmarks.ts` | Local bookmarks state | + +### API + +| File | Purpose | +| ---------------------------- | ------------- | +| `src/utils/auth/api.ts` | API functions | +| `src/utils/auth/apiPaths.ts` | URL builders | + +### Components + +| File | Purpose | +| ----------------------------------------------------------------------------------- | --------------------------------- | +| `src/components/QuranReader/TranslationView/BookmarkIcon.tsx` | Bookmark icon in translation view | +| `src/components/QuranReader/ContextMenu/components/PageBookmarkAction.tsx` | Page bookmark context menu | +| `src/components/Verse/SaveToCollectionAction.tsx` | Save to collection menu item | +| `src/components/Collection/SaveToCollectionModal/SaveToCollectionModal.tsx` | Collection selection modal | +| `src/components/Collection/CollectionList/CollectionList.tsx` | Collections list view | +| `src/components/Collection/CollectionDetailContainer/CollectionDetailContainer.tsx` | Collection detail view | +| `src/components/Verses/BookmarkedVersesList.tsx` | Recent bookmarks list | +| `src/components/Verses/BookmarksAndCollectionsSection.tsx` | Tabbed bookmarks/collections | + +### Pages + +| File | Purpose | +| ------------------------------------------------ | ----------------------------- | +| `src/pages/collections/all/index.tsx` | All bookmarks aggregated view | +| `src/pages/collections/[collectionId]/index.tsx` | Collection detail page | diff --git a/docs/COMMON_MISTAKES.md b/docs/COMMON_MISTAKES.md new file mode 100644 index 0000000000..566f7ce52d --- /dev/null +++ b/docs/COMMON_MISTAKES.md @@ -0,0 +1,653 @@ +# Common Frontend Mistakes + +> Patterns we've shipped bugs from. Check every PR against this list. + +--- + +## 🔴 Critical Mistakes (Caused Production Issues) + +### 1. Wrong Rendering Strategy + +**Symptom**: Poor SEO, slow page loads, unnecessary server costs. + +```tsx +// ❌ Bad: CSR for SEO-critical public pages (Google can't index) +const SurahPage = () => { + const { data } = useSWR(`/api/surah/${id}`); + return ; +}; + +// ❌ Bad: SSR for static content (unnecessary server load every request) +export const getServerSideProps = async ({ params }) => { + const surah = await fetchSurah(params.id); // Quran text doesn't change! + return { props: { surah } }; +}; + +// ✅ Good: SSG for static, SEO-critical content +export const getStaticProps = async ({ params }) => { + const surah = await fetchSurah(params.id); + return { props: { surah } }; +}; + +export const getStaticPaths = async () => ({ + paths: Array.from({ length: 114 }, (_, i) => ({ params: { id: String(i + 1) } })), + fallback: false, +}); + +// ✅ Good: ISR for content that updates occasionally +export const getStaticProps = async ({ params }) => { + const reflections = await fetchPopularReflections(); + return { props: { reflections }, revalidate: 3600 }; // refresh hourly +}; +``` + +**Quick Reference**: + +| Content Type | Strategy | Example | +| --------------------------------- | -------- | ---------------------------------- | +| Never changes + SEO needed | SSG | Surah pages, Juz pages | +| Updates periodically + SEO needed | ISR | Popular reflections, curated lists | +| User-specific + SEO needed | SSR | Public user profiles | +| User-specific + no SEO | CSR | Dashboard, bookmarks, settings | + +### 2. Missing Error State + +**Symptom**: API fails, user sees blank screen or stale data. + +```tsx +// ❌ Bad: No error handling +const { data } = useSWR('/api/reflections'); +return ; + +// ✅ Good: Handle all states +const { data, error, isLoading } = useSWR('/api/reflections'); +if (isLoading) return ; +if (error) return ; +if (!data?.items?.length) return ; +return ; +``` + +### 3. Trusting API Response Shape + +**Symptom**: `Cannot read property 'x' of undefined` in production. + +```tsx +// ❌ Bad: Assumes structure exists +const title = response.data.verse.translation.text; + +// ✅ Good: Safe access with fallback +const title = response?.data?.verse?.translation?.text ?? ''; +``` + +### 4. useSWR + useState Duplication + +**Symptom**: UI shows stale data, state gets out of sync. + +```tsx +// ❌ Bad: Duplicating SWR data into state +const { data } = useSWR('/api/bookmarks'); +const [bookmarks, setBookmarks] = useState([]); +useEffect(() => { + if (data) setBookmarks(data); +}, [data]); + +// ✅ Good: Use SWR's cache directly, mutate for updates +const { data: bookmarks, mutate } = useSWR('/api/bookmarks'); +const addBookmark = async (id) => { + mutate([...bookmarks, { id }], false); // optimistic + await api.addBookmark(id); + mutate(); // revalidate +}; +``` + +### 5. Unhandled Promise Rejection + +**Symptom**: Silent failures, broken features with no feedback. + +```tsx +// ❌ Bad: No error handling +const handleSubmit = async () => { + await api.submitReflection(text); + router.push('/success'); +}; + +// ✅ Good: Handle failure +const handleSubmit = async () => { + try { + await api.submitReflection(text); + router.push('/success'); + } catch (error) { + logError(error); + toast.error(t('common:error.submission-failed')); + } +}; +``` + +--- + +## 🟠 Frequent Mistakes (Cause Bugs or Bad UX) + +### 6. Missing Loading State + +**Symptom**: Layout shift, content pops in awkwardly. + +```tsx +// ❌ Bad: No loading indicator +const { data } = useSWR('/api/verses'); +return ; + +// ✅ Good: Skeleton while loading +const { data, isLoading } = useSWR('/api/verses'); +if (isLoading) return ; +return ; +``` + +### 7. Missing Empty State + +**Symptom**: Weird UI when list is empty (blank space, broken layout). + +```tsx +// ❌ Bad: Just renders empty list +return ( +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+); + +// ✅ Good: Handle empty case +if (!items?.length) { + return ; +} +return ( +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+); +``` + +### 8. Array Index as Key + +**Symptom**: Wrong items update, weird re-render bugs. + +```tsx +// ❌ Bad: Index as key +{ + items.map((item, index) => ); +} + +// ✅ Good: Stable unique ID +{ + items.map((item) => ); +} +``` + +### 9. Hardcoded Strings + +**Symptom**: Arabic users see English text, broken i18n. + +```tsx +// ❌ Bad: Hardcoded + +

No results found

+ +// ✅ Good: Localized + +

{t('search:no-results')}

+``` + +### 10. CSS Left/Right Instead of Logical Properties + +**Symptom**: Broken RTL layout (Arabic, Urdu users). + +```scss +// ❌ Bad: Physical properties +.card { + margin-left: 16px; + padding-right: 8px; + text-align: left; + border-left: 2px solid; +} + +// ✅ Good: Logical properties +.card { + margin-inline-start: 16px; + padding-inline-end: 8px; + text-align: start; + border-inline-start: 2px solid; +} +``` + +### 11. Div with onClick Instead of Button + +**Symptom**: Not keyboard accessible, no focus state, screen readers ignore it. + +```tsx +// ❌ Bad: Inaccessible +
+ Click me +
+ +// ✅ Good: Semantic + accessible + + +// ✅ Also good: If it navigates, use Link + + Go to verse + +``` + +--- + +## 🟡 Code Quality Mistakes + +### 12. Console.log in Production + +```tsx +// ❌ Bad: Debug code shipped +console.log('data', data); + +// ✅ Good: Use proper logging utility or remove +logDebug('Fetched verses', { count: data.length }); +``` + +### 13. Unnecessary useEffect + +**Rule**: If you can derive it, don't effect it. + +```tsx +// ❌ Bad: Derived state in effect +const [isValid, setIsValid] = useState(false); +useEffect(() => { + setIsValid(email.includes('@') && password.length >= 8); +}, [email, password]); + +// ✅ Good: Derive directly +const isValid = email.includes('@') && password.length >= 8; +``` + +### 14. Commented-Out Code + +```tsx +// ❌ Bad: Dead code as comments +// const oldImplementation = () => { ... } +// TODO: maybe use this later? + +// ✅ Good: Delete it. Git history has it if needed. +``` + +### 15. Giant Components + +**Rule**: If a component is > 200 lines, it's doing too much. + +```tsx +// ❌ Bad: 500-line component with mixed concerns + +// ✅ Good: Split by responsibility +// - VersePage.tsx (layout + data fetching) +// - VerseHeader.tsx (title, navigation) +// - VerseContent.tsx (ayah display) +// - VerseActions.tsx (bookmark, share, reflect) +``` + +### 16. Any Type Without Justification + +```tsx +// ❌ Bad: Silent any +const processData = (input: any) => { ... } + +// ✅ Acceptable: Justified escape hatch +// eslint-disable-next-line @typescript-eslint/no-explicit-any +// Reason: Legacy API returns untyped response, typing tracked in QF-1234 +const processLegacy = (input: any) => { ... } +``` + +--- + +## 🔵 Clean Code Violations + +### 17. Violating DRY (Repeated Code) + +**Symptom**: Same logic in multiple places, bug fixed in one but not others. + +```tsx +// ❌ Bad: Date formatting duplicated across components +// In ComponentA.tsx +const formatted = new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +// In ComponentB.tsx +const formatted = new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +// In ComponentC.tsx +const formatted = new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + +// ✅ Good: Single source of truth +// utils/formatters.ts +export const formatShortDate = (date: string) => + new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + +// All components +import { formatShortDate } from '@/utils/formatters'; +const formatted = formatShortDate(date); +``` + +### 18. Violating KISS (Over-Engineering) + +**Symptom**: Simple task buried in abstraction layers. + +```tsx +// ❌ Bad: Factory pattern for a simple fetch +const createApiFetcherFactory = (baseConfig) => (endpointConfig) => (params) => + fetch(buildUrl(baseConfig, endpointConfig, params), mergeConfigs(baseConfig, endpointConfig)); + +const fetchVerse = createApiFetcherFactory({ base: API_URL })({ endpoint: 'verses' }); +const verse = await fetchVerse({ id: 1 }); + +// ✅ Good: Just fetch the thing +const fetchVerse = async (id: string) => { + const response = await fetch(`${API_URL}/verses/${id}`); + return response.json(); +}; +const verse = await fetchVerse('1'); +``` + +### 19. Violating Single Responsibility + +**Symptom**: Component does many unrelated things, hard to test/modify. + +```tsx +// ❌ Bad: One component does everything +const VersePage = () => { + // Fetching + const [verse, setVerse] = useState() + useEffect(() => { fetchVerse(id).then(setVerse) }, [id]) + + // Analytics + useEffect(() => { trackPageView('verse', id) }, [id]) + + // Audio control + const [isPlaying, setIsPlaying] = useState(false) + const audioRef = useRef() + + // Bookmarking + const [isBookmarked, setIsBookmarked] = useState(false) + const handleBookmark = async () => { ... } + + // Rendering all of it + return ( +
+
+ ) +} + +// ✅ Good: Separated concerns +const VersePage = ({ id }: { id: string }) => { + const verse = useVerse(id) // Custom hook for fetching + useTrackPageView('verse', id) // Custom hook for analytics + + return ( +
+ + + +
+ ) +} +``` + +### 20. Not Using Composition + +**Symptom**: Props list keeps growing, component becomes rigid. + +```tsx +// ❌ Bad: Prop explosion + + +// ✅ Good: Composition + + + + Reflection + By Ahmed + + {content} + + + + + + +``` + +--- + +## 🔵 SOLID Principles (React Edition) + +### 21. Single Responsibility Violation + +**Rule**: Each component/hook does ONE thing. + +```tsx +// ❌ Bad: Component does fetching + formatting + rendering + tracking +const VersePage = () => { + const [verse, setVerse] = useState() + useEffect(() => { fetch(...).then(format).then(setVerse).then(track) }, []) + return
...
+} + +// ✅ Good: Separated concerns +const VersePage = () => { + const verse = useVerse(verseKey) // hook handles fetching + useTrackView(verseKey) // hook handles analytics + return // component handles rendering +} +``` + +### 22. Open/Closed Violation + +**Rule**: Extend via props/composition, don't modify existing components for new cases. + +```tsx +// ❌ Bad: Modifying component for each new case +const Button = ({ type }) => { + if (type === 'primary') return diff --git a/src/components/AudioPlayer/Buttons/PlayPauseButton.tsx b/src/components/AudioPlayer/Buttons/PlayPauseButton.tsx index d18aaabd53..69c1b47ed0 100644 --- a/src/components/AudioPlayer/Buttons/PlayPauseButton.tsx +++ b/src/components/AudioPlayer/Buttons/PlayPauseButton.tsx @@ -29,6 +29,7 @@ const PlayPauseButton = () => { shape={ButtonShape.Circle} variant={ButtonVariant.Ghost} isDisabled={isLoading} + data-testid="audio-loading-button" > @@ -44,6 +45,7 @@ const PlayPauseButton = () => { logButtonClick('audio_player_pause'); audioService.send('TOGGLE'); })} + data-testid="audio-pause-toggle" > @@ -59,6 +61,7 @@ const PlayPauseButton = () => { audioService.send('TOGGLE'); })} shouldFlipOnRTL={false} + data-testid="audio-play-toggle" > diff --git a/src/components/AudioPlayer/Buttons/SeekButton.tsx b/src/components/AudioPlayer/Buttons/SeekButton.tsx new file mode 100644 index 0000000000..66738319ce --- /dev/null +++ b/src/components/AudioPlayer/Buttons/SeekButton.tsx @@ -0,0 +1,61 @@ +import { useContext, useMemo } from 'react'; + +import { useSelector } from '@xstate/react'; +import useTranslation from 'next-translate/useTranslation'; + +import Button, { ButtonShape, ButtonVariant } from '@/dls/Button/Button'; +import BackwardIcon from '@/icons/backward.svg'; +import ForwardIcon from '@/icons/forward.svg'; +import { getChapterData } from '@/utils/chapter'; +import { logButtonClick } from '@/utils/eventLogger'; +import DataContext from 'src/contexts/DataContext'; +import { AudioPlayerMachineContext } from 'src/xstate/AudioPlayerMachineContext'; + +export enum SeekButtonType { + NextAyah = 'nextAyah', + PrevAyah = 'prevAyah', +} + +type SeekButtonProps = { + type: SeekButtonType; + isLoading: boolean; +}; +const SeekButton = ({ type, isLoading }: SeekButtonProps) => { + const audioService = useContext(AudioPlayerMachineContext); + const chaptersData = useContext(DataContext); + + const surah = useSelector(audioService, (state) => state.context.surah); + const ayahNumber = useSelector(audioService, (state) => state.context.ayahNumber); + + const chapterData = useMemo( + () => getChapterData(chaptersData, surah?.toString()), + [chaptersData, surah], + ); + + const { t } = useTranslation('common'); + + const onSeek = () => { + // eslint-disable-next-line i18next/no-literal-string + logButtonClick(`audio_player_${type}`); + audioService.send({ type: type === SeekButtonType.NextAyah ? 'NEXT_AYAH' : 'PREV_AYAH' }); + }; + + const isDisabled = + isLoading || + (type === SeekButtonType.PrevAyah && ayahNumber <= 1) || + (type === SeekButtonType.NextAyah && ayahNumber >= chapterData?.versesCount); + + return ( + + ); +}; + +export default SeekButton; diff --git a/src/components/AudioPlayer/OverflowAudioPlayActionsMenuBody.tsx b/src/components/AudioPlayer/OverflowAudioPlayActionsMenuBody.tsx index 6d86a2bb7f..af2fb9dba9 100644 --- a/src/components/AudioPlayer/OverflowAudioPlayActionsMenuBody.tsx +++ b/src/components/AudioPlayer/OverflowAudioPlayActionsMenuBody.tsx @@ -104,7 +104,7 @@ const OverflowAudioPlayActionsMenuBody = () => { setSelectedMenu(AudioPlayerOverflowMenu.AudioSpeed); }} > -
+
{t('audio.speed')}
diff --git a/src/components/AudioPlayer/OverflowAudioPlayerActionsMenu.module.scss b/src/components/AudioPlayer/OverflowAudioPlayerActionsMenu.module.scss index 7bdfd6b4fe..b939200d04 100644 --- a/src/components/AudioPlayer/OverflowAudioPlayerActionsMenu.module.scss +++ b/src/components/AudioPlayer/OverflowAudioPlayerActionsMenu.module.scss @@ -17,7 +17,7 @@ // inset-block-start: initial !important; // inset-block-end: calc(1.5 * var(--spacing-mega)) !important; // position: absolute !important; -// z-index: var(--z-index-max); +// z-index: var(--z-index-high); // inset-inline-start: 100% !important; // transform: translate3d(-100%, 0, 0) !important; diff --git a/src/components/AudioPlayer/RepeatAudioModal/RepeatAudioModal.tsx b/src/components/AudioPlayer/RepeatAudioModal/RepeatAudioModal.tsx index d18c75f9fd..d933b248e2 100644 --- a/src/components/AudioPlayer/RepeatAudioModal/RepeatAudioModal.tsx +++ b/src/components/AudioPlayer/RepeatAudioModal/RepeatAudioModal.tsx @@ -173,7 +173,7 @@ const RepeatAudioModal = ({ {t('audio.player.repeat-settings')} {`${t('surah')} ${chapterName}`} -
+
{ + +const SeekButton: React.FC = ({ type, isLoading }) => { const audioService = useContext(AudioPlayerMachineContext); const chaptersData = useContext(DataContext); const surah = useSelector(audioService, (state) => state.context.surah); const ayahNumber = useSelector(audioService, (state) => state.context.ayahNumber); - const chapterData = useMemo( - () => getChapterData(chaptersData, surah?.toString()), - [chaptersData, surah], - ); + const chapterData = useMemo(() => { + if (!chaptersData || !surah) { + return undefined; + } + return getChapterData(chaptersData, surah.toString()); + }, [chaptersData, surah]); const { t } = useTranslation('common'); @@ -43,7 +46,7 @@ const SeekButton = ({ type, isLoading }: SeekButtonProps) => { const isDisabled = isLoading || (type === SeekButtonType.PrevAyah && ayahNumber <= 1) || - (type === SeekButtonType.NextAyah && ayahNumber >= chapterData?.versesCount); + (type === SeekButtonType.NextAyah && ayahNumber >= (chapterData?.versesCount ?? 0)); return ( diff --git a/src/components/Banner/Banner.module.scss b/src/components/Banner/Banner.module.scss index 6e94a4fa80..a8f73863b6 100644 --- a/src/components/Banner/Banner.module.scss +++ b/src/components/Banner/Banner.module.scss @@ -1,83 +1,38 @@ -@use "src/styles/breakpoints"; - .container { - margin-block-start: calc(-1 * var(--banner-height)); - transition: var(--transition-regular); + margin-block-start: 0; box-sizing: border-box; height: var(--banner-height); - position: relative; - background-color: var(--color-background-lighten); + background-color: var(--color-background-elevated-new); display: flex; justify-content: space-between; align-items: center; + gap: var(--spacing-small-px); - padding-inline: var(--spacing-large); - padding-block: var(--spacing-xsmall); - - @include breakpoints.tablet { - position: absolute; - display: inline-flex; - background-color: transparent; - padding-inline: var(--spacing-mega); - justify-content: center; - max-width: calc(20 * var(--spacing-mega)); - inset-inline-start: 0; - inset-inline-end: 0; - margin-inline-start: auto; - margin-inline-end: auto; - } -} -.isVisible { - margin-block-start: 0; + padding-inline: var(--spacing-small-px); + padding-block: var(--spacing-xsmall-px); } -.description { - display: flex; - align-items: center; - font-size: 14px; // hard coded value, because it's not available in our design system +.text { + color: var(--color-text-default-new); font-weight: var(--font-weight-semibold); - color: var(--color-text-default); - @include breakpoints.tablet { - font-size: var(--font-size-normal); - margin-inline-end: var(--spacing-large); - } + font-size: var(--font-size-small-px); } -.illustrationContainer { - display: flex; - @include breakpoints.tablet { - display: inline-flex; - height: var(--spacing-mega); - width: var(--spacing-mega); - } - height: var(--spacing-large); - width: var(--spacing-large); - margin-inline-end: var(--spacing-xxsmall); -} - -.ctaContainer { - @include breakpoints.tablet { - padding-inline-end: calc(1.5 * var(--spacing-medium)); - } -} - -.closeButton { - color: var(--color-success-medium); - - height: 100%; - position: absolute; - inset-block-start: 0; +.cta { + padding-inline: var(--spacing-xsmall-px); + padding-block: var(--spacing-micro2-px); + background-color: var(--color-success-faded); + color: var(--color-text-link-new); + font-size: var(--font-size-small-px); + font-weight: var(--font-weight-medium); display: flex; + justify-content: center; align-items: center; - - inset-inline-end: var(--spacing-small); - @include breakpoints.tablet { - position: relative; - margin-inline-start: var(--spacing-xxsmall); - } + gap: var(--spacing-xxsmall-px); + border-radius: var(--border-radius-medium-px); + text-decoration: none; } -.text { - line-height: normal; - color: var(--color-text-default-new); +.icon > svg path { + fill: var(--color-text-link-new); } diff --git a/src/components/Banner/Banner.tsx b/src/components/Banner/Banner.tsx index 4bfb20c877..62ae94315f 100644 --- a/src/components/Banner/Banner.tsx +++ b/src/components/Banner/Banner.tsx @@ -1,35 +1,64 @@ -import classNames from 'classnames'; -import { useSelector } from 'react-redux'; +import { useCallback, useMemo } from 'react'; import styles from './Banner.module.scss'; -import MoonIllustrationSVG from '@/public/images/moon-illustration.svg'; -import { selectIsBannerVisible } from '@/redux/slices/banner'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import Link, { LinkVariant } from '@/dls/Link/Link'; +import useGetStreakWithMetadata from '@/hooks/auth/useGetStreakWithMetadata'; +import useIsLoggedIn from '@/hooks/auth/useIsLoggedIn'; +import DiamondIcon from '@/icons/diamond.svg'; +import { logButtonClick } from '@/utils/eventLogger'; +import { + getReadingGoalNavigationUrl, + getReadingGoalProgressNavigationUrl, +} from '@/utils/navigation'; -type BannerProps = { +interface BannerProps { text: string; - ctaButton?: React.ReactNode; - shouldShowPrefixIcon?: boolean; -}; + ctaButtonText?: string; +} + +const Banner = ({ text, ctaButtonText }: BannerProps) => { + const isLoggedIn = useIsLoggedIn(); + const { goal, isLoading } = useGetStreakWithMetadata(); + const hasGoal = !!goal; + + // Route logged-in users with an existing goal to the progress page, + // otherwise route to the reading-goal page. + // When isLoading is false, the API call has completed and hasGoal accurately reflects goal status. + const ctaLink = useMemo(() => { + return isLoggedIn && !isLoading && hasGoal + ? getReadingGoalProgressNavigationUrl() + : getReadingGoalNavigationUrl(); + }, [isLoggedIn, isLoading, hasGoal]); -const Banner = ({ text, ctaButton, shouldShowPrefixIcon = true }: BannerProps) => { - const isBannerVisible = useSelector(selectIsBannerVisible); + const handleButtonClick = useCallback(() => { + logButtonClick('banner_cta', { + hasGoal, + isLoggedIn, + }); + }, [hasGoal, isLoggedIn]); return ( -
-
- {shouldShowPrefixIcon && ( -
- -
- )} -
{text}
-
- {ctaButton &&
{ctaButton}
} +
+
{text}
+ {ctaButtonText && ( + +
); }; diff --git a/src/components/Collection/CollectionDetailContainer/CollectionDetailContainer.tsx b/src/components/Collection/CollectionDetailContainer/CollectionDetailContainer.tsx index e4f16348f2..dc99a17b20 100644 --- a/src/components/Collection/CollectionDetailContainer/CollectionDetailContainer.tsx +++ b/src/components/Collection/CollectionDetailContainer/CollectionDetailContainer.tsx @@ -10,6 +10,7 @@ import styles from './CollectionDetailContainer.module.scss'; import NextSeoWrapper from '@/components/NextSeoWrapper'; import Spinner, { SpinnerSize } from '@/dls/Spinner/Spinner'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; +import useBookmarkCacheInvalidator from '@/hooks/useBookmarkCacheInvalidator'; import ArrowLeft from '@/icons/west.svg'; import Error from '@/pages/_error'; import { logButtonClick } from '@/utils/eventLogger'; @@ -49,6 +50,7 @@ const CollectionDetailContainer = ({ const router = useRouter(); const collectionId = router.query.collectionId as string; const toast = useToast(); + const { invalidateAllBookmarkCaches } = useBookmarkCacheInvalidator(); const { data, size, setSize, mutate, isValidating, error } = useSWRInfinite(getSWRKey, privateFetcher); @@ -67,6 +69,7 @@ const CollectionDetailContainer = ({ const onUpdated = () => { mutate(); + invalidateAllBookmarkCaches(); }; const lastPageData = data[data.length - 1]; diff --git a/src/components/Collection/CollectionList/CollectionList.tsx b/src/components/Collection/CollectionList/CollectionList.tsx index 3c8fca3568..68c17b253b 100644 --- a/src/components/Collection/CollectionList/CollectionList.tsx +++ b/src/components/Collection/CollectionList/CollectionList.tsx @@ -16,6 +16,7 @@ import ConfirmationModal from '@/dls/ConfirmationModal/ConfirmationModal'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; import OverflowMenuIcon from '@/icons/menu_more_horiz.svg'; import BookmarkIcon from '@/icons/unbookmarked.svg'; +import BookmarkType from '@/types/BookmarkType'; import { logButtonClick, logEvent, logValueChange } from '@/utils/eventLogger'; import { toLocalizedNumber } from '@/utils/locale'; import Button, { ButtonShape, ButtonSize, ButtonVariant } from 'src/components/dls/Button/Button'; @@ -34,6 +35,7 @@ const CollectionList = () => { const [sortBy, setSortBy] = useState(DEFAULT_SORT_OPTION); const apiParams = { sortBy, + type: BookmarkType.Ayah, }; const { data, mutate } = useSWR(makeCollectionsUrl(apiParams), () => diff --git a/src/components/Collection/CollectionList/DeleteCollectionAction.tsx b/src/components/Collection/CollectionList/DeleteCollectionAction.tsx index 72f022e022..a7f594966b 100644 --- a/src/components/Collection/CollectionList/DeleteCollectionAction.tsx +++ b/src/components/Collection/CollectionList/DeleteCollectionAction.tsx @@ -2,6 +2,7 @@ import useTranslation from 'next-translate/useTranslation'; import { useConfirm } from '@/dls/ConfirmationModal/hooks'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; +import useBookmarkCacheInvalidator from '@/hooks/useBookmarkCacheInvalidator'; import { logButtonClick } from '@/utils/eventLogger'; import PopoverMenu from 'src/components/dls/PopoverMenu/PopoverMenu'; import { deleteCollection } from 'src/utils/auth/api'; @@ -10,6 +11,7 @@ const DeleteCollectionAction = ({ collectionId, onDone, collectionName }) => { const { t } = useTranslation(); const toast = useToast(); const confirm = useConfirm(); + const { invalidateAllBookmarkCaches } = useBookmarkCacheInvalidator(); const onMenuItemClicked = async () => { logButtonClick('delete_collection_action_open', { @@ -28,6 +30,7 @@ const DeleteCollectionAction = ({ collectionId, onDone, collectionName }) => { }); deleteCollection(collectionId) .then(() => { + invalidateAllBookmarkCaches(); onDone(); }) .catch(() => { diff --git a/src/components/Collection/SaveToCollectionModal/SaveToCollectionModal.tsx b/src/components/Collection/SaveToCollectionModal/SaveToCollectionModal.tsx index d4cb883801..7edc4fe9ea 100644 --- a/src/components/Collection/SaveToCollectionModal/SaveToCollectionModal.tsx +++ b/src/components/Collection/SaveToCollectionModal/SaveToCollectionModal.tsx @@ -15,7 +15,11 @@ import { logButtonClick, logEvent } from '@/utils/eventLogger'; import { RuleType } from 'types/FieldRule'; import { FormFieldType } from 'types/FormField'; -export type Collection = { +/** + * UI-specific collection type for the save modal with checked state + * This is different from types/Collection which is the API response type + */ +export type CollectionOption = { id: string; name: string; checked?: boolean; @@ -23,8 +27,8 @@ export type Collection = { type SaveToCollectionModalProps = { isOpen: boolean; - collections: Collection[]; - onCollectionToggled: (collection: Collection, newValue: boolean) => void; + collections: CollectionOption[]; + onCollectionToggled: (collection: CollectionOption, newValue: boolean) => void; onNewCollectionCreated: (name: string) => Promise; onClose?: () => void; verseKey: string; @@ -68,7 +72,7 @@ const SaveToCollectionModal = ({ logButtonClick('save_to_collection_add_new_collection'); }; - const handleCheckboxChange = (collection: Collection) => (checked: boolean) => { + const handleCheckboxChange = (collection: CollectionOption) => (checked: boolean) => { const eventData = { verseKey, collectionId: collection.id, @@ -96,7 +100,7 @@ const SaveToCollectionModal = ({
diff --git a/src/components/Course/Buttons/StartOrContinueLearning/index.tsx b/src/components/Course/Buttons/StartOrContinueLearning/index.tsx index f145845d46..77e5ed44e4 100644 --- a/src/components/Course/Buttons/StartOrContinueLearning/index.tsx +++ b/src/components/Course/Buttons/StartOrContinueLearning/index.tsx @@ -24,7 +24,6 @@ const StartOrContinueLearning: React.FC = ({ course, isHeaderButton = tru */ const redirectToLessonSlug = continueFromLesson || lessons?.[0]?.slug; const router = useRouter(); - const userCompletedAnyLesson = lessons.some((lesson) => lesson.isCompleted === true); const onContinueLearningClicked = () => { logButtonClick('continue_learning', { courseId: id, @@ -33,18 +32,7 @@ const StartOrContinueLearning: React.FC = ({ course, isHeaderButton = tru router.push(getLessonNavigationUrl(slug, redirectToLessonSlug)); }; - const onStartLearningClicked = () => { - logButtonClick('start_learning', { - courseId: id, - isHeaderButton, - }); - router.push(getLessonNavigationUrl(slug, redirectToLessonSlug)); - }; - - if (userCompletedAnyLesson) { - return ; - } - return ; + return ; }; export default StartOrContinueLearning; diff --git a/src/components/Course/CourseDetails/StatusHeader/index.tsx b/src/components/Course/CourseDetails/StatusHeader/index.tsx index f3144df082..cbf441bbae 100644 --- a/src/components/Course/CourseDetails/StatusHeader/index.tsx +++ b/src/components/Course/CourseDetails/StatusHeader/index.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-func/max-lines-per-function */ -import React, { useState } from 'react'; +import React from 'react'; import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; @@ -10,12 +10,11 @@ import StartOrContinueLearning from '@/components/Course/Buttons/StartOrContinue import CourseFeedback, { FeedbackSource } from '@/components/Course/CourseFeedback'; import Button from '@/dls/Button/Button'; import Pill from '@/dls/Pill'; -import { ToastStatus, useToast } from '@/dls/Toast/Toast'; -import useMutateWithoutRevalidation from '@/hooks/useMutateWithoutRevalidation'; +import { useToast, ToastStatus } from '@/dls/Toast/Toast'; import { Course } from '@/types/auth/Course'; -import { enrollUser } from '@/utils/auth/api'; -import { makeGetCourseUrl } from '@/utils/auth/apiPaths'; -import { isLoggedIn } from '@/utils/auth/login'; +import EnrollmentMethod from '@/types/auth/EnrollmentMethod'; +import { getUserType, isLoggedIn } from '@/utils/auth/login'; +import useCourseEnrollment from '@/utils/auth/useCourseEnrollment'; import { logButtonClick } from '@/utils/eventLogger'; import { getCourseNavigationUrl, @@ -29,61 +28,64 @@ type Props = { }; const StatusHeader: React.FC = ({ course, isCTA = false }) => { - const { title, id, isUserEnrolled, slug, isCompleted, lessons } = course; - const [isLoading, setIsLoading] = useState(false); - const toast = useToast(); + const { id, isUserEnrolled, slug, isCompleted, lessons, allowGuestAccess } = course; const router = useRouter(); const { t } = useTranslation('learn'); - const mutate = useMutateWithoutRevalidation(); + const toast = useToast(); + const userLoggedIn = isLoggedIn(); + const { enroll, isEnrolling } = useCourseEnrollment(slug); - const onEnrollClicked = () => { - if (isLoggedIn()) { - logButtonClick('user_enroll_course', { courseId: id, isCTA }); - setIsLoading(true); - enrollUser(course.id) - .then(() => { - toast( - t('enroll-success', { - title, - }), - { - status: ToastStatus.Success, - }, - ); - mutate(makeGetCourseUrl(slug), (currentCourse: Course) => { - return { - ...currentCourse, - isUserEnrolled: true, - }; - }); - // if the course has lessons, redirect to the first lesson - if (lessons?.length > 0) { - router.replace(getLessonNavigationUrl(slug, lessons[0].slug)); - } - }) - .catch(() => { - toast(t('common:error.general'), { - status: ToastStatus.Error, - }); - }) - .finally(() => { - setIsLoading(false); - }); - } else { - logButtonClick('guest_enroll_course', { courseId: id, isCTA }); - router.replace(getLoginNavigationUrl(getCourseNavigationUrl(slug))); + const redirectToFirstLesson = () => { + if (lessons?.length > 0) { + router.replace(getLessonNavigationUrl(slug, lessons[0].slug)); } }; - if (isCTA) { - if (isUserEnrolled === true) { - return <>; + const handleUnauthenticatedUser = () => { + if (allowGuestAccess) { + redirectToFirstLesson(); + return; + } + const redirectUrl = getCourseNavigationUrl(slug); + router.replace(getLoginNavigationUrl(redirectUrl)); + }; + + const onStartHereClicked = async (): Promise => { + const userType = getUserType(userLoggedIn); + + logButtonClick('course_enroll', { + courseId: id, + isCTA, + userType, + }); + + if (!userLoggedIn) { + handleUnauthenticatedUser(); + return; } + + const enrollmentResult = await enroll(id, EnrollmentMethod.MANUAL); + if (enrollmentResult.success) { + redirectToFirstLesson(); + } else { + toast(t('common:error.general'), { + status: ToastStatus.Error, + }); + } + }; + + const renderStartHereButton = () => { return ( - ); + }; + if (isCTA) { + if (isUserEnrolled) { + return <>; + } + return renderStartHereButton(); } if (isCompleted) { return ( @@ -95,15 +97,11 @@ const StatusHeader: React.FC = ({ course, isCTA = false }) => {
); } - if (isUserEnrolled === true) { + if (isUserEnrolled) { return ; } - return ( - - ); + return renderStartHereButton(); }; export default StatusHeader; diff --git a/src/components/Course/CourseDetails/Tabs/MainDetails/MainDetails.module.scss b/src/components/Course/CourseDetails/Tabs/MainDetails/MainDetails.module.scss index 0a3d59a181..8d228f67a0 100644 --- a/src/components/Course/CourseDetails/Tabs/MainDetails/MainDetails.module.scss +++ b/src/components/Course/CourseDetails/Tabs/MainDetails/MainDetails.module.scss @@ -1,6 +1,7 @@ .htmlDescription { p + p, - ul + p { + ul + p, + ol + p { margin-top: var(--spacing-large); } } diff --git a/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss b/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss index 4ced154c20..94c976483b 100644 --- a/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss +++ b/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss @@ -1,11 +1,11 @@ .syllabusContainer { - padding-block-start: var(--spacing-xsmall); + padding-block-start: var(--spacing-xsmall); } .container { - padding-block: var(--spacing-micro); + padding-block: var(--spacing-micro); } .day { - font-weight: var(--font-weight-bold); -} + font-weight: var(--font-weight-bold); +} \ No newline at end of file diff --git a/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx b/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx index 0250e49dbd..27adbe08c6 100644 --- a/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx +++ b/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx @@ -8,26 +8,32 @@ import styles from './Syllabus.module.scss'; import CompletedTick from '@/components/Course/CompletedTick'; import Link, { LinkVariant } from '@/dls/Link/Link'; import { Course } from '@/types/auth/Course'; -import { isLoggedIn } from '@/utils/auth/login'; +import { getUserType } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; import { toLocalizedNumber } from '@/utils/locale'; -import { getLessonNavigationUrl, getLoginNavigationUrl } from '@/utils/navigation'; +import { getLessonNavigationUrl } from '@/utils/navigation'; type Props = { course: Course; }; const Syllabus: React.FC = ({ course }) => { - const { lessons = [], slug: courseSlug } = course; + const { lessons = [], slug: courseSlug, id: courseId } = course; const { t, lang } = useTranslation('learn'); - const isUserLoggedIn = isLoggedIn(); + const userType = getUserType(); - const onDayClick = (dayNumber: number, lessonId: string) => { - logButtonClick(isUserLoggedIn ? 'course_syllabus_day' : 'guest_course_syllabus_day', { - courseId: course.id, + /** + * Log syllabus lesson click for analytics + * @param {number} dayNumber - The day number of the lesson + * @param {string} lessonId - The ID of the lesson + */ + const logSyllabusClick = (dayNumber: number, lessonId: string) => { + logButtonClick('course_syllabus_day', { + courseId, dayNumber, lessonId, + userType, }); }; @@ -47,13 +53,13 @@ const Syllabus: React.FC = ({ course }) => { {`: `} onDayClick(dayNumber, id)} - href={isUserLoggedIn ? url : getLoginNavigationUrl(url)} + onClick={() => logSyllabusClick(dayNumber, id)} + href={url} variant={LinkVariant.Highlight} > {title} - {isCompleted ? : ''} + {isCompleted ? : null}

); diff --git a/src/components/Course/CoursesList/index.tsx b/src/components/Course/CoursesList/index.tsx index d8d8e3a5fe..4a6408e35c 100644 --- a/src/components/Course/CoursesList/index.tsx +++ b/src/components/Course/CoursesList/index.tsx @@ -64,7 +64,7 @@ const CoursesList: React.FC = ({ courses, isMyCourses }) => { return (
-
+
{courses.map((course) => { const { slug, id, continueFromLesson, title, isCompleted, thumbnail } = course; const navigateTo = continueFromLesson diff --git a/src/components/Course/CoursesPageLayout/index.tsx b/src/components/Course/CoursesPageLayout/index.tsx index 2a70017e6b..dcd5ad52d9 100644 --- a/src/components/Course/CoursesPageLayout/index.tsx +++ b/src/components/Course/CoursesPageLayout/index.tsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import Trans from 'next-translate/Trans'; import useTranslation from 'next-translate/useTranslation'; +import { useSelector } from 'react-redux'; import styles from './CoursesPageLayout.module.scss'; @@ -10,7 +11,8 @@ import CoursesList from '@/components/Course/CoursesList'; import DataFetcher from '@/components/DataFetcher'; import Spinner from '@/dls/Spinner/Spinner'; import layoutStyles from '@/pages/index.module.scss'; -import { CoursesResponse } from '@/types/auth/Course'; +import { selectLearningPlanLanguageIsoCodes } from '@/redux/slices/defaultSettings'; +import { Course, CoursesResponse } from '@/types/auth/Course'; import { privateFetcher } from '@/utils/auth/api'; import { makeGetCoursesUrl } from '@/utils/auth/apiPaths'; @@ -18,10 +20,22 @@ const Loading = () => ; type Props = { isMyCourses?: boolean; + initialCourses?: Course[]; }; -const CoursesPageLayout: React.FC = ({ isMyCourses = false }) => { +const CoursesPageLayout: React.FC = ({ isMyCourses = false, initialCourses }) => { const { t } = useTranslation('learn'); + const languageIsoCodes = useSelector(selectLearningPlanLanguageIsoCodes); + + const renderCourses = (courses: Course[] | undefined) => { + if (!courses) { + return ; + } + return ; + }; + + const shouldUseInitialData = !isMyCourses && initialCourses; + return (
@@ -40,14 +54,19 @@ const CoursesPageLayout: React.FC = ({ isMyCourses = false }) => { )}
- ( - - )} - /> + {shouldUseInitialData ? ( + renderCourses(initialCourses) + ) : ( + renderCourses(data.data)} + /> + )}
diff --git a/src/components/Course/LessonContent/index.tsx b/src/components/Course/LessonContent/index.tsx new file mode 100644 index 0000000000..429c50a0c8 --- /dev/null +++ b/src/components/Course/LessonContent/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import LessonView from '@/components/Course/LessonView'; +import NextSeoWrapper from '@/components/NextSeoWrapper'; +import { Lesson } from '@/types/auth/Course'; +import { getCanonicalUrl, getLessonNavigationUrl } from '@/utils/navigation'; + +interface Props { + lesson: Lesson; + lessonSlugOrId: string; + courseSlug: string; +} + +const LessonContent: React.FC = ({ lesson, lessonSlugOrId, courseSlug }) => { + const { lang } = useTranslation('learn'); + return ( + <> + + + + ); +}; + +export default LessonContent; diff --git a/src/components/Course/LessonView/ActionButtons/AddReflectionModal.module.scss b/src/components/Course/LessonView/ActionButtons/AddReflectionModal.module.scss index f00f5af7ca..69191abc70 100644 --- a/src/components/Course/LessonView/ActionButtons/AddReflectionModal.module.scss +++ b/src/components/Course/LessonView/ActionButtons/AddReflectionModal.module.scss @@ -1,23 +1,22 @@ .contentModal { - padding: var(--spacing-medium); + padding: var(--spacing-medium); } .header { - font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); } .closeIcon { - color: var(--color-text-default); - padding-block-start: 0; - margin-block-start: var(--spacing-small-px); + color: var(--color-text-default); + padding-block-start: 0; } .modalContent { - max-width: 100%; - padding-block-start: var(--spacing-small-px); + max-inline-size: 100%; + padding-block-start: var(--spacing-small-px); - p { - margin-bottom: var(--spacing-small-px); - font-size: var(--font-medium-small); - } + p { + margin-block-end: var(--spacing-small-px); + font-size: var(--font-medium-small); + } } diff --git a/src/components/Course/LessonView/ActionButtons/CompleteButton.tsx b/src/components/Course/LessonView/ActionButtons/CompleteButton.tsx index 1ea66a6177..ce32b0693e 100644 --- a/src/components/Course/LessonView/ActionButtons/CompleteButton.tsx +++ b/src/components/Course/LessonView/ActionButtons/CompleteButton.tsx @@ -1,10 +1,13 @@ import React from 'react'; +import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; import Button, { ButtonSize } from '@/dls/Button/Button'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; +import { getUserType, isLoggedIn } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; +import { getLoginNavigationUrl } from '@/utils/navigation'; type Props = { isLoading: boolean; @@ -15,11 +18,20 @@ type Props = { const CompleteButton: React.FC = ({ isLoading, id, markLessonAsCompleted }) => { const { t } = useTranslation('learn'); const toast = useToast(); + const router = useRouter(); + const userIsLoggedIn = isLoggedIn(); + const userType = getUserType(userIsLoggedIn); const onMarkAsCompletedClicked = () => { logButtonClick('mark_lesson_as_completed', { lessonId: id, + userType, }); + + if (!userIsLoggedIn) { + router.replace(getLoginNavigationUrl(encodeURIComponent(router.asPath))); + return; + } markLessonAsCompleted(id, () => { toast(t('mark-complete-success'), { status: ToastStatus.Success, @@ -32,6 +44,7 @@ const CompleteButton: React.FC = ({ isLoading, id, markLessonAsCompleted isDisabled={isLoading} size={ButtonSize.Small} onClick={onMarkAsCompletedClicked} + data-testid="lesson-mark-complete-button" > {t('mark-complete')} diff --git a/src/components/Course/LessonView/ActionButtons/ReflectionButton.tsx b/src/components/Course/LessonView/ActionButtons/ReflectionButton.tsx index 316331aaba..e6a5663360 100644 --- a/src/components/Course/LessonView/ActionButtons/ReflectionButton.tsx +++ b/src/components/Course/LessonView/ActionButtons/ReflectionButton.tsx @@ -7,6 +7,7 @@ import AddReflectionModal from './AddReflectionModal'; import Button, { ButtonSize, ButtonType } from '@/dls/Button/Button'; import QuestionMarkIcon from '@/icons/question-mark.svg'; +import { getUserType } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; import { getQRNavigationUrl } from '@/utils/quranReflect/navigation'; @@ -20,9 +21,12 @@ const ReflectionButton: React.FC = ({ lessonId, isCompleted }) => { const [isReflectionModalOpen, setIsReflectionModalOpen] = useState(false); const onAddReflectionClick = () => { + const userType = getUserType(); + logButtonClick('add_lesson_reflection', { lessonId, isCompleted, + userType, }); }; diff --git a/src/components/Course/LessonView/Lesson.module.scss b/src/components/Course/LessonView/Lesson.module.scss index e5561dbc53..f975bc1960 100644 --- a/src/components/Course/LessonView/Lesson.module.scss +++ b/src/components/Course/LessonView/Lesson.module.scss @@ -1,81 +1,82 @@ -@use "src/styles/breakpoints"; +@use 'src/styles/breakpoints'; .viewContainer { - @include breakpoints.desktop { - display: flex; - align-items: flex-start; - } + @include breakpoints.desktop { + display: flex; + align-items: flex-start; + } } .modalHeading { - font-size: var(--font-size-large); - font-weight: var(--font-weight-bold); - margin-block-end: var(--spacing-small); + font-size: var(--font-size-large); + font-weight: var(--font-weight-bold); + margin-block-end: var(--spacing-small); } .container { - min-block-size: 70vh; - @include breakpoints.desktop { - width: 50%; - } + min-block-size: 70vh; + @include breakpoints.desktop { + inline-size: 50%; + } } .backText { - padding-inline-start: var(--spacing-micro); + padding-inline-start: var(--spacing-micro); } .headerContainer { - padding-block-end: var(--spacing-small); - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; + padding-block-end: var(--spacing-small); + display: flex; + justify-content: space-between; + align-items: center; + inline-size: 100%; } .title { - font-weight: var(--font-weight-bold); - font-size: var(--font-size-jumbo); - padding-inline-end: var(--spacing-small); + font-weight: var(--font-weight-bold); + font-size: var(--font-size-jumbo); + padding-inline-end: var(--spacing-small); } .tickIcon { - span { - align-items: center; - } - &:hover { - svg { - path { - color: var(--color-background-inverse); - stroke: var(--color-background-inverse); - } - } - } + span { + align-items: center; + } + &:hover { svg { - path { - color: var(--color-background-default); - stroke: var(--color-background-default); - stroke-width: 1; - } + path { + color: var(--color-background-inverse); + stroke: var(--color-background-inverse); + } + } + } + svg { + path { + color: var(--color-background-default); + stroke: var(--color-background-default); + stroke-width: 1; } + } } .contentContainer { - min-height: calc(var(--spacing-mega) * 10); + min-block-size: calc(var(--spacing-mega) * 10); } .htmlContent { p + p, - ul + p { + ul + p, + ol + p { margin-top: var(--spacing-large); } } .headerButtonsContainer { - display: flex; - align-items: center; - justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; } .courseMaterialButton { - @include breakpoints.desktop { - display: none; - } + @include breakpoints.desktop { + display: none; + } } diff --git a/src/components/Course/LessonView/index.tsx b/src/components/Course/LessonView/index.tsx index a1eabe1c30..f2fa6e3fde 100644 --- a/src/components/Course/LessonView/index.tsx +++ b/src/components/Course/LessonView/index.tsx @@ -65,7 +65,7 @@ const LessonView: React.FC = ({ lesson, courseSlug, lessonSlugOrId }) => currentLessonId={lesson.id} lessons={lesson.course.lessons} /> -
+
+

{title}

+
+
+
+ ); +}; + +export default HeaderNavigation; diff --git a/src/components/HomePage/Card/index.tsx b/src/components/HomePage/Card/index.tsx index 35a38f3a9a..e3142b0be8 100644 --- a/src/components/HomePage/Card/index.tsx +++ b/src/components/HomePage/Card/index.tsx @@ -13,6 +13,7 @@ interface CardProps { className?: string; linkClassName?: string; onClick?: () => void; + shouldPrefetch?: boolean; } const Card: React.FC = ({ @@ -22,10 +23,17 @@ const Card: React.FC = ({ className, linkClassName, onClick, + shouldPrefetch = true, }) => { if (link) { return ( - +
{children}
); diff --git a/src/components/HomePage/CommunitySection/index.tsx b/src/components/HomePage/CommunitySection/index.tsx index 3c87d31ec9..f80955f863 100644 --- a/src/components/HomePage/CommunitySection/index.tsx +++ b/src/components/HomePage/CommunitySection/index.tsx @@ -24,13 +24,14 @@ const CommunitySection = () => {

{t('common:community.title')}

-
+
diff --git a/src/components/HomePage/ExploreTopicsSection/ExploreTopicsSection.module.scss b/src/components/HomePage/ExploreTopicsSection/ExploreTopicsSection.module.scss index ed2ea7fb7c..2f3bd4c065 100644 --- a/src/components/HomePage/ExploreTopicsSection/ExploreTopicsSection.module.scss +++ b/src/components/HomePage/ExploreTopicsSection/ExploreTopicsSection.module.scss @@ -1,57 +1,57 @@ -@use "src/styles/breakpoints"; +@use 'src/styles/breakpoints'; .header { - padding-block-end: var(--spacing-medium); + padding-block-end: var(--spacing-medium); + @include breakpoints.tablet { + padding-block-end: var(--spacing-large-px); + } + + h1 { + font-size: var(--font-size-xlarge-px); + font-weight: var(--font-weight-bold); @include breakpoints.tablet { - padding-block-end: var(--spacing-large-px); - } - - h1 { - font-size: var(--font-size-xlarge-px); - font-weight: var(--font-weight-bold); - @include breakpoints.tablet { - font-size: var(--font-size-xxlarge-px); - } + font-size: var(--font-size-xxlarge-px); } + } } .topic { - background-color: var(--color-topics-grey); - padding: var(--spacing-medium-px) !important; - min-width: max-content; - display: flex; - flex-direction: row; - align-items: center; - color: var(--color-text-default-new); - white-space: nowrap; - - @include breakpoints.tablet { - font-size: var(--font-size-large-px); - } + background-color: var(--color-topics-grey); + padding: var(--spacing-medium-px) !important; + min-inline-size: max-content; + display: flex; + flex-direction: row; + align-items: center; + color: var(--color-text-default-new); + white-space: nowrap; + + @include breakpoints.tablet { + font-size: var(--font-size-large-px); + } } .container { - display: flex; - width: 100%; - gap: var(--spacing-small); - flex-wrap: nowrap; - - @include breakpoints.smallerThanTablet { - gap: var(--spacing-xxsmall); - } - - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: none; - -ms-overflow-style: none; - -webkit-overflow-scrolling: touch; - scroll-behavior: smooth; - - &::-webkit-scrollbar { - display: none; - } + display: flex; + inline-size: 100%; + gap: var(--spacing-small); + flex-wrap: nowrap; + + @include breakpoints.smallerThanTablet { + gap: var(--spacing-xxsmall); + } + + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + + &::-webkit-scrollbar { + display: none; + } } .highlighted { - background-color: var(--color-topics-gold); -} \ No newline at end of file + background-color: var(--color-topics-gold); +} diff --git a/src/components/HomePage/ExploreTopicsSection/index.tsx b/src/components/HomePage/ExploreTopicsSection/index.tsx index 5bccab81b8..cadd3aa9ff 100644 --- a/src/components/HomePage/ExploreTopicsSection/index.tsx +++ b/src/components/HomePage/ExploreTopicsSection/index.tsx @@ -9,6 +9,8 @@ import Button, { ButtonShape, ButtonSize, ButtonType, ButtonVariant } from '@/dl import ArrowIcon from '@/public/icons/arrow.svg'; import { logButtonClick } from '@/utils/eventLogger'; +const isProduction = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'; + const TOPICS = [ { slug: 'about-the-quran', @@ -21,11 +23,15 @@ const TOPICS = [ // logKey: 'jesus-in-quran', // key: 'jesus-in-quran', // }, - { - slug: 'collections/the-authority-and-importance-of-the-sunnah-clem7p7lf15921610rsdk4xzulfj', - key: 'sunnah', - logKey: 'sunnah_collection', - }, + ...(isProduction + ? [ + { + slug: 'collections/the-authority-and-importance-of-the-sunnah-clem7p7lf15921610rsdk4xzulfj', + key: 'sunnah', + logKey: 'sunnah_collection', + }, + ] + : []), { slug: 'what-is-ramadan', logKey: 'what-is-ramadan', @@ -40,7 +46,7 @@ const ExploreTopicsSection = () => {

{t('home:explore-topics')}

-
+
{TOPICS.map((topic) => { return ( diff --git a/src/components/HomePage/ReadingSection/NewCard/ShareQuranModal.module.scss b/src/components/HomePage/ReadingSection/NewCard/ShareQuranModal.module.scss index a831a8223d..ee58e1e7d6 100644 --- a/src/components/HomePage/ReadingSection/NewCard/ShareQuranModal.module.scss +++ b/src/components/HomePage/ReadingSection/NewCard/ShareQuranModal.module.scss @@ -1,14 +1,27 @@ @use "src/styles/breakpoints"; +.description { + margin-block-start: var(--spacing-xxsmall-px); + margin-block-end: var(--spacing-large-px); +} + .subtitle { text-align: center; - margin-block: var(--spacing-medium); color: var(--color-text-default); - font-size: var(--spacing-medium2-px); + font-size: var(--font-size-large); + white-space: pre-line; +} + +.bold { + font-weight: var(--font-weight-semibold); +} + +.gray { + color: var(--color-text-gray); } .title { - font-size: calc(var(--spacing-large-px) + var(--spacing-micro-px)); + font-size: var(--font-size-jumbo); } .closeButton { diff --git a/src/components/HomePage/ReadingSection/NewCard/index.tsx b/src/components/HomePage/ReadingSection/NewCard/index.tsx index d8b0a6bddd..fbf72ba39f 100644 --- a/src/components/HomePage/ReadingSection/NewCard/index.tsx +++ b/src/components/HomePage/ReadingSection/NewCard/index.tsx @@ -37,6 +37,7 @@ const NewCard: React.FC = () => { className={styles.firstTimeReadingCard} link={getTakeNotesNavigationUrl()} isNewTab + shouldPrefetch={false} >
@@ -55,6 +56,7 @@ const NewCard: React.FC = () => { className={styles.linkHref} onClick={onTakeNotesClicked} isNewTab + shouldPrefetch={false} /> ), }} diff --git a/src/components/HomePage/ReadingSection/NoGoalOrStreakCard/index.tsx b/src/components/HomePage/ReadingSection/NoGoalOrStreakCard/index.tsx index 7cc0445718..aaa1e03de3 100644 --- a/src/components/HomePage/ReadingSection/NoGoalOrStreakCard/index.tsx +++ b/src/components/HomePage/ReadingSection/NoGoalOrStreakCard/index.tsx @@ -7,20 +7,13 @@ import styles from '@/components/HomePage/ReadingSection/ReadingSection.module.s import IconContainer, { IconSize } from '@/dls/IconContainer/IconContainer'; import CirclesIcon from '@/icons/circles.svg'; import ArrowIcon from '@/public/icons/arrow.svg'; -import { isLoggedIn } from '@/utils/auth/login'; -import { getLoginNavigationUrl, getReadingGoalNavigationUrl } from '@/utils/navigation'; +import { getReadingGoalNavigationUrl } from '@/utils/navigation'; const NoGoalOrStreakCard = () => { const { t } = useTranslation('home'); return ( - +
diff --git a/src/components/HomePage/ReadingSection/StreakOrGoalCard/StreakOrGoalCard.module.scss b/src/components/HomePage/ReadingSection/StreakOrGoalCard/StreakOrGoalCard.module.scss index 73e354c638..8e2ae5e369 100644 --- a/src/components/HomePage/ReadingSection/StreakOrGoalCard/StreakOrGoalCard.module.scss +++ b/src/components/HomePage/ReadingSection/StreakOrGoalCard/StreakOrGoalCard.module.scss @@ -1,107 +1,112 @@ -@use "src/styles/breakpoints"; +@use 'src/styles/breakpoints'; + +.outerStreakCard { + display: block; + inline-size: 100%; +} .streakCard { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; + inline-size: 100%; + display: flex; + align-items: center; + justify-content: space-between; } .streak { - padding-inline-end: var(--spacing-small-px); - font-size: var(--font-size-jumbo); - font-weight: var(--font-weight-bold); - color: var(--color-text-black); - @include breakpoints.tablet { - font-size: var(--font-size-xjumbo-px); - } + padding-inline-end: var(--spacing-small-px); + font-size: var(--font-size-jumbo); + font-weight: var(--font-weight-bold); + color: var(--color-text-black); + @include breakpoints.tablet { + font-size: var(--font-size-xjumbo-px); + } } .streakCardLeft { - display: flex; - align-items: center; - gap: var(--spacing-micro); - & > svg:first-child { - padding-inline-start: var(--spacing-small-px); - padding-inline-end: var(--spacing-small-px); + display: flex; + align-items: center; + gap: var(--spacing-micro); + & > svg:first-child { + padding-inline-start: var(--spacing-small-px); + padding-inline-end: var(--spacing-small-px); - width: var(--spacing-medium2-px); - height: var(--spacing-medium2-px); - @include breakpoints.tablet { - width: var(--spacing-large-px); - height: var(--spacing-large-px); - padding-inline-start: var(--spacing-medium-px); - padding-inline-end: var(--spacing-medium2-px); - width: 60px; - height: 60px; - } + inline-size: var(--spacing-medium2-px); + block-size: var(--spacing-medium2-px); + @include breakpoints.tablet { + inline-size: var(--spacing-large-px); + block-size: var(--spacing-large-px); + padding-inline-start: var(--spacing-medium-px); + padding-inline-end: var(--spacing-medium2-px); + width: 60px; + height: 60px; } + } } .customGoalButton { - background-color: var(--color-streaks-dark); - color: var(--color-text-white); - font-weight: var(--font-weight-bold); - svg { - path { - fill: var(--color-text-white); - } - } - &:hover { - background-color: var(--color-streaks-dark); - } - @include breakpoints.tablet { - font-size: var(--font-size-normal-px); + background-color: var(--color-streaks-dark); + color: var(--color-text-white); + font-weight: var(--font-weight-bold); + svg { + path { + fill: var(--color-text-white); } + } + &:hover { + background-color: var(--color-streaks-dark); + } + @include breakpoints.tablet { + font-size: var(--font-size-normal-px); + } } .circularProgressbar { - height: 60px; - width: 60px; + block-size: 60px; + inline-size: 60px; } .circularProgressbarContainer { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - gap: var(--spacing-micro); + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: var(--spacing-micro); } .streakDay { - font-size: var(--font-size-normal); - color: var(--color-text-gray); - font-weight: var(--font-weight-semibold); - @include breakpoints.tablet { - font-size: var(--font-size-large-px); - } + font-size: var(--font-size-normal); + color: var(--color-text-gray); + font-weight: var(--font-weight-semibold); + @include breakpoints.tablet { + font-size: var(--font-size-large-px); + } } .circularProgressbarPath { - stroke: var(--color-streaks-dark); - stroke-linecap: round; - transition: stroke-dashoffset 0.5s ease 0s; + stroke: var(--color-streaks-dark); + stroke-linecap: round; + transition: stroke-dashoffset 0.5s ease 0s; } .circularProgressbarTrail { - stroke: var(--color-daily-progress); - /* Used when trail is not full diameter, i.e. when props.circleRatio is set */ - stroke-linecap: round; + stroke: var(--color-daily-progress); + /* Used when trail is not full diameter, i.e. when props.circleRatio is set */ + stroke-linecap: round; } .circularProgressbarText { - fill: var(--color-text-black); - font-size: 20px; - dominant-baseline: middle; - text-anchor: middle; - font-weight: var(--font-weight-bold); + fill: var(--color-text-black); + font-size: 20px; + dominant-baseline: middle; + text-anchor: middle; + font-weight: var(--font-weight-bold); } .goalArrowIcon { - svg { - @include breakpoints.smallerThanTablet { - width: 12px !important; - height: 12px !important; - } + svg { + @include breakpoints.smallerThanTablet { + inline-size: 12px !important; + block-size: 12px !important; } + } } diff --git a/src/components/HomePage/ReadingSection/StreakOrGoalCard/index.tsx b/src/components/HomePage/ReadingSection/StreakOrGoalCard/index.tsx index c398741385..4cbba3e7a2 100644 --- a/src/components/HomePage/ReadingSection/StreakOrGoalCard/index.tsx +++ b/src/components/HomePage/ReadingSection/StreakOrGoalCard/index.tsx @@ -1,15 +1,18 @@ import React from 'react'; +import classNames from 'classnames'; import Trans from 'next-translate/Trans'; import useTranslation from 'next-translate/useTranslation'; +import cardStyles from '../ReadingSection.module.scss'; + import GoalStatus from './GoalStatus'; import styles from './StreakOrGoalCard.module.scss'; +import Card from '@/components/HomePage/Card'; import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; import CircularProgressbar from '@/dls/CircularProgress'; import IconContainer, { IconSize } from '@/dls/IconContainer/IconContainer'; -import Link from '@/dls/Link/Link'; import PlantIcon from '@/icons/plant.svg'; import ArrowIcon from '@/public/icons/arrow.svg'; import CirclesIcon from '@/public/icons/circles.svg'; @@ -24,7 +27,7 @@ import { type Props = { currentActivityDay: CurrentQuranActivityDay; - goal: QuranGoalStatus; + goal?: QuranGoalStatus | null; streak: number; }; @@ -44,73 +47,79 @@ const StreakOrGoalCard: React.FC = ({ goal, streak, currentActivityDay }) }; return ( -
- -
- - , - span: , - }} - values={{ - days: toLocalizedNumber(streak, lang), - }} - i18nKey="reading-goal:x-days-streak" - /> - {!goal && ( - } - shouldForceSetColors={false} - /> - )} -
- -
- {goal ? ( -
-
- + +
+
+
+
+
+ + , + span: , + }} + values={{ + days: toLocalizedNumber(streak, lang), + }} + i18nKey="reading-goal:x-days-streak" + /> +
+ +
+ {goal ? ( +
+
+ +
+ + } + shouldForceSetColors={false} + className={styles.goalArrowIcon} + aria-hidden="true" + /> +
+ ) : ( + + )} +
- - - } - shouldForceSetColors={false} - className={styles.goalArrowIcon} - /> -
- ) : ( - - )} +
-
+ ); }; diff --git a/src/components/HomePage/ReadingSection/index.tsx b/src/components/HomePage/ReadingSection/index.tsx index 29250a1d47..a2ded435cb 100644 --- a/src/components/HomePage/ReadingSection/index.tsx +++ b/src/components/HomePage/ReadingSection/index.tsx @@ -1,7 +1,6 @@ /* eslint-disable max-lines */ import React from 'react'; -import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; import { useSelector } from 'react-redux'; @@ -11,7 +10,6 @@ import NoGoalOrStreakCard from './NoGoalOrStreakCard'; import styles from './ReadingSection.module.scss'; import StreakOrGoalCard from './StreakOrGoalCard'; -import Card from '@/components/HomePage/Card'; import Link, { LinkVariant } from '@/dls/Link/Link'; import useGetRecentlyReadVerseKeys from '@/hooks/auth/useGetRecentlyReadVerseKeys'; import useGetStreakWithMetadata from '@/hooks/auth/useGetStreakWithMetadata'; @@ -57,6 +55,7 @@ const ReadingSection: React.FC = () => { variant={LinkVariant.Blend} href={getProfileNavigationUrl()} onClick={onMyQuranClicked} + shouldPrefetch={false} >

{t('my-quran')}

@@ -74,13 +73,7 @@ const ReadingSection: React.FC = () => { const goalsOrStreakCard = streak || goal ? ( - -
-
- -
-
-
+ ) : ( <>{!isMobile() && } ); diff --git a/src/components/Login/AuthHeader.tsx b/src/components/Login/AuthHeader.tsx index 43972ab02f..9b57f62f52 100644 --- a/src/components/Login/AuthHeader.tsx +++ b/src/components/Login/AuthHeader.tsx @@ -1,25 +1,39 @@ import { FC } from 'react'; -import classNames from 'classnames'; +import Trans from 'next-translate/Trans'; +import useTranslation from 'next-translate/useTranslation'; import styles from './login.module.scss'; import QuranLogo from '@/icons/logo_main.svg'; -import QRColoredLogo from '@/icons/qr-colored.svg'; -import QRLogo from '@/icons/qr-logo.svg'; -const AuthHeader: FC = () => { +interface AuthHeaderProps { + showSubtitle?: boolean; + as?: React.ElementType; +} + +const AuthHeader: FC = ({ showSubtitle = false, as: As = 'div' }) => { + const { t } = useTranslation('login'); + return ( - <> -
- -
- - +
+ + , + }} + /> + + {showSubtitle && ( +
+

{t('sign-in-or-sign-up')}

+
+ +
-
-
- + )} +
); }; diff --git a/src/components/Login/AuthInput/AuthInput.module.scss b/src/components/Login/AuthInput/AuthInput.module.scss index d06782ac46..bb894d0665 100644 --- a/src/components/Login/AuthInput/AuthInput.module.scss +++ b/src/components/Login/AuthInput/AuthInput.module.scss @@ -1,50 +1,50 @@ /* Auth-specific container overrides */ .authContainer { - height: var(--spacing-xxlarge-px); - border-radius: var(--border-radius-medium-px) !important; - border: 1px solid var(--color-border-gray-lighter) !important; - width: 100%; - background-color: var(--color-background-default); - transition: border-color 0.2s ease; - box-sizing: border-box; + height: var(--spacing-xxlarge-px) !important; + border-radius: var(--border-radius-medium-px) !important; + border: 1px solid var(--color-border-gray-lighter) !important; + width: 100%; + background-color: var(--color-background-default); + transition: border-color 0.2s ease; + box-sizing: border-box; - &:hover, - &:focus-within { - border-color: var(--color-success-medium) !important; - } + &:hover, + &:focus-within { + border-color: var(--color-success-medium) !important; + } - &.disabled { - cursor: not-allowed; - background-color: var(--color-border-gray-faded); - border-color: var(--color-border-gray-lighter) !important; + &.disabled { + cursor: not-allowed; + background-color: var(--color-border-gray-faded); + border-color: var(--color-border-gray-lighter) !important; - &:hover, - &:focus-within { - border-color: var(--color-border-gray-lighter) !important; - } + &:hover, + &:focus-within { + border-color: var(--color-border-gray-lighter) !important; } + } } /* Auth-specific input overrides */ .authInput { - height: var(--spacing-xxlarge-px); - box-sizing: border-box; - border: none; - margin: 0; + height: var(--spacing-xxlarge-px); + box-sizing: border-box; + border: none; + margin: 0; - &::placeholder { - color: var(--color-border-gray-light) !important; - } + &::placeholder { + color: var(--color-border-gray-light) !important; + } - &:hover, - &:focus, - &.hasValue { - border: none; /* Let container handle the border */ - } + &:hover, + &:focus, + &.hasValue { + border: none; /* Let container handle the border */ + } - &:disabled { - cursor: not-allowed; - color: var(--color-border-gray-dark); - background-color: var(--color-border-gray-faded); - } + &:disabled { + cursor: not-allowed; + color: var(--color-border-gray-dark); + background-color: var(--color-border-gray-faded); + } } diff --git a/src/components/Login/AuthInput/index.tsx b/src/components/Login/AuthInput/index.tsx index 9ae906dc22..5e45dbd30d 100644 --- a/src/components/Login/AuthInput/index.tsx +++ b/src/components/Login/AuthInput/index.tsx @@ -16,6 +16,7 @@ interface AuthInputProps { name?: string; autoComplete?: string; className?: string; + dataTestId?: string; } /** @@ -34,6 +35,7 @@ const AuthInput: React.FC = ({ name, autoComplete, className, + dataTestId, }) => { return ( = ({ inputClassName={classNames(styles.authInput, { [styles.hasValue]: value, })} + dataTestId={dataTestId} {...(autoComplete && { autoComplete })} /> ); diff --git a/src/components/Login/AuthTabs.tsx b/src/components/Login/AuthTabs.tsx index 2c4a6ce554..8b0a737b18 100644 --- a/src/components/Login/AuthTabs.tsx +++ b/src/components/Login/AuthTabs.tsx @@ -6,6 +6,7 @@ import AuthHeader from './AuthHeader'; import styles from './login.module.scss'; import SignInForm from './SignInForm'; import SignUpForm from './SignUpForm'; +import SocialButtons from './SocialButtons'; import Switch, { SwitchSize } from '@/dls/Switch/Switch'; import SignUpRequest from 'types/auth/SignUpRequest'; @@ -38,9 +39,8 @@ const AuthTabs: FC = ({ activeTab, onTabChange, redirect, onSignUpSuccess return (
- +
-

{t('sign-in-or-sign-up')}

= ({ activeTab, onTabChange, redirect, onSignUpSuccess ) : ( )} +
+
+ {t('or')} +
+
+
); diff --git a/src/components/Login/BenefitsSection.tsx b/src/components/Login/BenefitsSection.tsx deleted file mode 100644 index 4b7a8e2db0..0000000000 --- a/src/components/Login/BenefitsSection.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { FC } from 'react'; - -import Feature from './Feature'; -import styles from './login.module.scss'; - -interface Benefit { - id: string; - label: string; -} - -interface BenefitsSectionProps { - benefits: Benefit[]; -} - -const BenefitsSection: FC = ({ benefits }) => ( -
- {benefits.map(({ id, label }) => ( - - ))} -
-); - -export default BenefitsSection; diff --git a/src/components/Login/CompleteSignupForm.tsx b/src/components/Login/CompleteSignupForm.tsx index a716ecddf2..4befa6d4e4 100644 --- a/src/components/Login/CompleteSignupForm.tsx +++ b/src/components/Login/CompleteSignupForm.tsx @@ -1,8 +1,11 @@ /* eslint-disable react-func/max-lines-per-function */ /* eslint-disable max-lines */ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; +import { useDispatch } from 'react-redux'; import { useSWRConfig } from 'swr'; import AuthHeader from './AuthHeader'; @@ -12,17 +15,19 @@ import styles from './login.module.scss'; import getFormErrors, { ErrorType } from './SignUpForm/errors'; import VerificationCodeForm from './VerificationCode/VerificationCodeForm'; -import Button, { ButtonShape, ButtonType } from '@/components/dls/Button/Button'; +import Button, { ButtonShape, ButtonSize, ButtonType } from '@/components/dls/Button/Button'; import FormBuilder from '@/components/FormBuilder/FormBuilder'; import authStyles from '@/styles/auth/auth.module.scss'; import UserProfile from '@/types/auth/UserProfile'; import { makeUserProfileUrl } from '@/utils/auth/apiPaths'; import { updateUserProfile } from '@/utils/auth/authRequests'; +import { syncPreferencesFromServer } from '@/utils/auth/syncPreferencesFromServer'; import { handleResendVerificationCode, handleVerificationCodeSubmit as submitVerificationCode, } from '@/utils/auth/verification'; import { logFormSubmission } from '@/utils/eventLogger'; +import { AudioPlayerMachineContext } from 'src/xstate/AudioPlayerMachineContext'; type FormData = { [key: string]: string; @@ -36,6 +41,9 @@ interface CompleteSignupFormProps { const CompleteSignupForm: React.FC = ({ onSuccess, userData }) => { const { t } = useTranslation('common'); const { mutate } = useSWRConfig(); + const router = useRouter(); + const dispatch = useDispatch(); + const audioService = useContext(AudioPlayerMachineContext); const [isSubmitting, setIsSubmitting] = useState(false); const [showVerification, setShowVerification] = useState(false); @@ -136,6 +144,16 @@ const CompleteSignupForm: React.FC = ({ onSuccess, user const handleVerificationCodeSubmit = async (code: string) => { try { const result = await submitVerificationCode(email, code); + try { + await syncPreferencesFromServer({ + locale: router.locale || 'en', + dispatch, + audioService, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to sync user preferences after completing signup', error); + } // Mutate the user profile data to update the global state mutate(result.profileUrl); @@ -161,9 +179,10 @@ const CompleteSignupForm: React.FC = ({ onSuccess, user diff --git a/src/components/Login/CompleteSignupFormFields/index.ts b/src/components/Login/CompleteSignupFormFields/index.ts index 40aad7fcaf..7b7fee88ee 100644 --- a/src/components/Login/CompleteSignupFormFields/index.ts +++ b/src/components/Login/CompleteSignupFormFields/index.ts @@ -27,6 +27,11 @@ const createModifiedField = ( modifiedField.label = t(`form.${field.field}`); modifiedField.placeholder = t(`form.${field.field}`); + // Add dataTestId if not already present + if (!modifiedField.dataTestId) { + modifiedField.dataTestId = `complete-signup-${field.field}-input`; + } + // Apply any additional overrides return { ...modifiedField, ...overrides }; }; diff --git a/src/components/Login/CompleteSignupFormWithCustomRender.tsx b/src/components/Login/CompleteSignupFormWithCustomRender.tsx index 54f580a7db..dde2a16453 100644 --- a/src/components/Login/CompleteSignupFormWithCustomRender.tsx +++ b/src/components/Login/CompleteSignupFormWithCustomRender.tsx @@ -42,6 +42,7 @@ const addCustomRenderToCompleteSignupFormFields = ( onChange: (value: string) => void; placeholder?: string; disabled?: boolean; + dataTestId?: string; }) => { return ( ); }, diff --git a/src/components/Login/Feature.tsx b/src/components/Login/Feature.tsx deleted file mode 100644 index 70f65ff7c0..0000000000 --- a/src/components/Login/Feature.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { FC } from 'react'; - -import styles from './login.module.scss'; - -import useDirection from '@/hooks/useDirection'; -import SunIcon from '@/icons/sun-login.svg'; - -interface Props { - label: string; -} - -const Feature: FC = ({ label }) => { - const direction = useDirection(); - - return ( -
-
- -

{label}

-
-
- ); -}; - -export default Feature; diff --git a/src/components/Login/ForgotPassword/ForgotPasswordForm.tsx b/src/components/Login/ForgotPassword/ForgotPasswordForm.tsx index 6dfe1ea6bc..391991b222 100644 --- a/src/components/Login/ForgotPassword/ForgotPasswordForm.tsx +++ b/src/components/Login/ForgotPassword/ForgotPasswordForm.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; +import classNames from 'classnames'; import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; @@ -10,7 +11,7 @@ import styles from '../login.module.scss'; import getFormErrors, { ErrorType } from '../SignUpForm/errors'; import { getEmailField } from '../SignUpFormFields/credentialFields'; -import Button, { ButtonShape, ButtonType } from '@/components/dls/Button/Button'; +import Button, { ButtonShape, ButtonSize, ButtonType } from '@/components/dls/Button/Button'; import FormBuilder from '@/components/FormBuilder/FormBuilder'; import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; @@ -62,9 +63,10 @@ const ForgotPasswordForm: React.FC = () => { diff --git a/src/components/Login/LoginContainer.tsx b/src/components/Login/LoginContainer.tsx index a5a7b05d04..a5e7954c0e 100644 --- a/src/components/Login/LoginContainer.tsx +++ b/src/components/Login/LoginContainer.tsx @@ -1,29 +1,26 @@ import { useState } from 'react'; import { useRouter } from 'next/router'; -import useTranslation from 'next-translate/useTranslation'; import AuthTabs, { AuthTab } from './AuthTabs'; import BackButton from './BackButton'; import PrivacyPolicyText from './PrivacyPolicyText'; -import ServiceCard from './ServiceCard'; import VerificationCodeForm from './VerificationCode/VerificationCodeForm'; import authStyles from '@/styles/auth/auth.module.scss'; import QueryParam from '@/types/QueryParam'; import { signUp } from '@/utils/auth/authRequests'; import { logButtonClick, logEvent } from '@/utils/eventLogger'; +import { resolveSafeRedirect } from '@/utils/url'; import SignUpRequest from 'types/auth/SignUpRequest'; enum LoginView { - SOCIAL = 'social', EMAIL = 'email', VERIFICATION = 'verification', } const LoginContainer = () => { - const { t } = useTranslation('login'); - const [loginView, setLoginView] = useState(LoginView.SOCIAL); + const [loginView, setLoginView] = useState(LoginView.EMAIL); const [activeTab, setActiveTab] = useState(AuthTab.SignIn); const [signUpData, setSignUpData] = useState | null>(null); const router = useRouter(); @@ -36,18 +33,14 @@ const LoginContainer = () => { logButtonClick('login_back'); if (loginView === LoginView.VERIFICATION) { setLoginView(LoginView.EMAIL); - } else if (loginView === LoginView.EMAIL) { - setLoginView(LoginView.SOCIAL); + } else if (redirect) { + const destination = resolveSafeRedirect(redirect); + router.push(destination); } else { router.back(); } }; - const onEmailLoginClick = () => { - logEvent('login_email_click'); - setLoginView(LoginView.EMAIL); - }; - const onTabChange = (tab: AuthTab) => { logEvent('login_tab_change', { tab }); setActiveTab(tab); @@ -86,47 +79,17 @@ const LoginContainer = () => { ); } - if (loginView === LoginView.EMAIL) { - return ( - <> - - - - - - ); - } - - const benefits = { - quran: [ - { id: 'feature-6', label: t('feature-6') }, - { id: 'feature-1', label: t('feature-1') }, - { id: 'feature-2', label: t('feature-2') }, - { id: 'feature-3', label: t('feature-3') }, - { id: 'feature-4', label: t('feature-4') }, - { id: 'feature-5', label: t('feature-5') }, - ], - reflect: [ - { id: 'reflect-1', label: t('reflect-feature-1') }, - { id: 'reflect-2', label: t('reflect-feature-2') }, - { id: 'reflect-3', label: t('reflect-feature-3') }, - { id: 'reflect-4', label: t('reflect-feature-4') }, - ], - }; - return ( - + <> + + {activeTab === AuthTab.SignUp && } + + ); }; diff --git a/src/components/Login/ResetPassword/ResetPasswordForm.tsx b/src/components/Login/ResetPassword/ResetPasswordForm.tsx index 28e9d1e215..06d7a7ae44 100644 --- a/src/components/Login/ResetPassword/ResetPasswordForm.tsx +++ b/src/components/Login/ResetPassword/ResetPasswordForm.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; +import classNames from 'classnames'; import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; @@ -9,7 +10,7 @@ import styles from '../login.module.scss'; import getFormErrors, { ErrorType } from '../SignUpForm/errors'; import getPasswordFields from '../SignUpForm/PasswordFields'; -import Button, { ButtonShape, ButtonType } from '@/components/dls/Button/Button'; +import Button, { ButtonShape, ButtonSize, ButtonType } from '@/components/dls/Button/Button'; import FormBuilder from '@/components/FormBuilder/FormBuilder'; import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; @@ -69,9 +70,10 @@ const ResetPasswordForm: React.FC = () => { diff --git a/src/components/Login/ServiceCard.tsx b/src/components/Login/ServiceCard.tsx deleted file mode 100644 index 0dbc7f9019..0000000000 --- a/src/components/Login/ServiceCard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { FC } from 'react'; - -import useTranslation from 'next-translate/useTranslation'; - -import BackButton from './BackButton'; -import BenefitsSection from './BenefitsSection'; -import styles from './login.module.scss'; -import PrivacyPolicyText from './PrivacyPolicyText'; -import SocialButtons from './SocialButtons'; - -import { SubmissionResult } from '@/components/FormBuilder/FormBuilder'; -import EmailLogin, { EmailLoginData } from '@/components/Login/EmailLogin'; -import QuranLogo from '@/icons/logo_main.svg'; -import QRColoredLogo from '@/icons/qr-colored.svg'; -import QRLogo from '@/icons/qr-logo.svg'; - -interface Benefit { - id: string; - label: string; -} - -interface Props { - benefits: { - quran: Benefit[]; - reflect: Benefit[]; - }; - isEmailLogin?: boolean; - onEmailLoginSubmit?: (data: { email: string }) => SubmissionResult; - onOtherOptionsClicked?: () => void; - onBackClick?: () => void; - redirect?: string; -} - -const ServiceCard: FC = ({ - benefits, - isEmailLogin, - onEmailLoginSubmit, - onOtherOptionsClicked, - onBackClick, - redirect, -}) => { - const { t } = useTranslation('login'); - - const renderEmailLogin = () => ( - - ); - - const renderWelcomeContent = () => ( - <> -

{t('welcome-title')}

-

- {t('welcome-description-1')} {t('quran-text')} - {t('welcome-description-2')} -
- {t('welcome-description-3')} -

-
- - -
-
- - -
- -
-

{t('login-cta')}

- - - {onBackClick && } - - - - ); - - return ( -
- {isEmailLogin ? renderEmailLogin() : renderWelcomeContent()} -
- ); -}; - -export default ServiceCard; diff --git a/src/components/Login/SignInForm.tsx b/src/components/Login/SignInForm.tsx index ff0ea59ef8..8f4f23b9e5 100644 --- a/src/components/Login/SignInForm.tsx +++ b/src/components/Login/SignInForm.tsx @@ -1,6 +1,8 @@ -import { FC, useState } from 'react'; +import { FC, useContext, useState } from 'react'; +import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; +import { useDispatch } from 'react-redux'; import AuthInput from './AuthInput'; import styles from './login.module.scss'; @@ -10,14 +12,16 @@ import { getEmailField } from './SignUpFormFields/credentialFields'; import FormBuilder from '@/components/FormBuilder/FormBuilder'; import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes'; -import Button, { ButtonShape, ButtonType } from '@/dls/Button/Button'; +import Button, { ButtonShape, ButtonSize, ButtonType } from '@/dls/Button/Button'; import Link, { LinkVariant } from '@/dls/Link/Link'; import useAuthRedirect from '@/hooks/auth/useAuthRedirect'; import { RuleType } from '@/types/FieldRule'; import { FormFieldType } from '@/types/FormField'; import { signIn } from '@/utils/auth/authRequests'; +import { syncPreferencesFromServer } from '@/utils/auth/syncPreferencesFromServer'; import { logFormSubmission } from '@/utils/eventLogger'; import { getForgotPasswordNavigationUrl } from '@/utils/navigation'; +import { AudioPlayerMachineContext } from 'src/xstate/AudioPlayerMachineContext'; interface FormData { email: string; @@ -30,13 +34,22 @@ interface Props { const SignInForm: FC = ({ redirect }) => { const { t } = useTranslation('login'); + + const router = useRouter(); + const dispatch = useDispatch(); + const audioService = useContext(AudioPlayerMachineContext); + const { redirectWithToken } = useAuthRedirect(); + const [isSubmitting, setIsSubmitting] = useState(false); const formFields: FormBuilderFormField[] = [ { ...getEmailField(t), - customRender: (props) => , + dataTestId: 'signin-email-input', + customRender: (props) => ( + + ), errorClassName: styles.errorText, containerClassName: styles.inputContainer, }, @@ -44,6 +57,7 @@ const SignInForm: FC = ({ redirect }) => { field: 'password', type: FormFieldType.Password, placeholder: t('password-placeholder'), + dataTestId: 'signin-password-input', rules: [ { type: RuleType.Required, @@ -68,7 +82,23 @@ const SignInForm: FC = ({ redirect }) => { return getFormErrors(t, ErrorType.API, errors); } - redirectWithToken(redirect || '/', response?.token); + let targetLocale = router.locale || 'en'; + try { + const { appliedLocale } = await syncPreferencesFromServer({ + locale: targetLocale, + dispatch, + audioService, + }); + if (appliedLocale) { + targetLocale = appliedLocale; + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to sync user preferences after login', error); + } + + redirectWithToken(redirect || '/', response?.token, targetLocale); + return undefined; } catch (error) { setIsSubmitting(false); @@ -89,11 +119,13 @@ const SignInForm: FC = ({ redirect }) => { ); diff --git a/src/components/Login/SignInForm/SignInPasswordField.tsx b/src/components/Login/SignInForm/SignInPasswordField.tsx index 52c2c6e831..1aba017d14 100644 --- a/src/components/Login/SignInForm/SignInPasswordField.tsx +++ b/src/components/Login/SignInForm/SignInPasswordField.tsx @@ -6,10 +6,16 @@ interface Props { value: string; onChange: (value: string) => void; placeholder?: string; + dataTestId?: string; } -const SignInPasswordField: FC = ({ value = '', onChange, placeholder }) => ( - +const SignInPasswordField: FC = ({ value = '', onChange, placeholder, dataTestId }) => ( + ); export default SignInPasswordField; diff --git a/src/components/Login/SignUpForm.tsx b/src/components/Login/SignUpForm.tsx index 87f1dcb784..2531364d63 100644 --- a/src/components/Login/SignUpForm.tsx +++ b/src/components/Login/SignUpForm.tsx @@ -1,3 +1,6 @@ +import { useState } from 'react'; + +import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; import styles from './login.module.scss'; @@ -6,7 +9,7 @@ import addCustomRenderToFormFields from './SignUpFormWithCustomRender'; import FormBuilder from '@/components/FormBuilder/FormBuilder'; import getSignUpFormFields from '@/components/Login/SignUpFormFields'; -import Button, { ButtonShape, ButtonType } from '@/dls/Button/Button'; +import Button, { ButtonShape, ButtonSize, ButtonType } from '@/dls/Button/Button'; import { signUp } from '@/utils/auth/authRequests'; import { logFormSubmission } from '@/utils/eventLogger'; import SignUpRequest from 'types/auth/SignUpRequest'; @@ -17,6 +20,7 @@ interface Props { const SignUpForm = ({ onSuccess }: Props) => { const { t } = useTranslation('login'); + const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (data: SignUpRequest) => { logFormSubmission('sign_up'); @@ -25,16 +29,21 @@ const SignUpForm = ({ onSuccess }: Props) => { return getFormErrors(t, ErrorType.MISMATCH); } + setIsSubmitting(true); + try { const { data: response, errors } = await signUp(data); if (!response.success) { + setIsSubmitting(false); return getFormErrors(t, ErrorType.API, errors); } + setIsSubmitting(false); onSuccess(data); return undefined; } catch (error) { + setIsSubmitting(false); return getFormErrors(t, ErrorType.SIGNUP); } }; @@ -43,9 +52,11 @@ const SignUpForm = ({ onSuccess }: Props) => { @@ -60,6 +71,7 @@ const SignUpForm = ({ onSuccess }: Props) => { formFields={formFields} onSubmit={handleSubmit} renderAction={renderAction} + isSubmitting={isSubmitting} shouldSkipValidation />
diff --git a/src/components/Login/SignUpForm/ConfirmPasswordField.tsx b/src/components/Login/SignUpForm/ConfirmPasswordField.tsx index 354cad4b4c..784b5f9eb6 100644 --- a/src/components/Login/SignUpForm/ConfirmPasswordField.tsx +++ b/src/components/Login/SignUpForm/ConfirmPasswordField.tsx @@ -6,10 +6,16 @@ interface Props { value: string; onChange: (value: string) => void; placeholder?: string; + dataTestId?: string; } -const ConfirmPasswordField: FC = ({ value, onChange, placeholder }) => ( - +const ConfirmPasswordField: FC = ({ value, onChange, placeholder, dataTestId }) => ( + ); export default ConfirmPasswordField; diff --git a/src/components/Login/SignUpForm/PasswordField.tsx b/src/components/Login/SignUpForm/PasswordField.tsx index 8dd69c0226..6e71593497 100644 --- a/src/components/Login/SignUpForm/PasswordField.tsx +++ b/src/components/Login/SignUpForm/PasswordField.tsx @@ -4,14 +4,34 @@ import PasswordInput from './PasswordInput'; import PasswordValidation from './PasswordValidation'; interface Props { + label?: string; value: string; onChange: (value: string) => void; placeholder?: string; + containerClassName?: string; + isDisabled?: boolean; + dataTestId?: string; } -const PasswordField: FC = ({ value = '', onChange, placeholder }) => ( +const PasswordField: FC = ({ + label, + value = '', + onChange, + placeholder, + containerClassName, + isDisabled = false, + dataTestId, +}) => ( <> - + ); diff --git a/src/components/Login/SignUpForm/PasswordFields.tsx b/src/components/Login/SignUpForm/PasswordFields.tsx index 37996bf441..66f123ec8d 100644 --- a/src/components/Login/SignUpForm/PasswordFields.tsx +++ b/src/components/Login/SignUpForm/PasswordFields.tsx @@ -15,19 +15,41 @@ const getPasswordFields = ( passwordPlaceholderKey = 'password-placeholder', confirmPasswordPlaceholderKey = 'confirm-password-placeholder', ): FormBuilderFormField[] => { - const PasswordInput: FC<{ - value: string; - onChange: (value: string) => void; - placeholder?: string; - }> = ({ value, onChange, placeholder }) => ( - - ); + const renderPasswordField = (isConfirmPassword: boolean) => { + const PasswordComponent: FC<{ + value: string; + onChange: (value: string) => void; + placeholder?: string; + dataTestId?: string; + }> = ({ value, onChange, placeholder, dataTestId }) => { + if (isConfirmPassword) { + return ( + + ); + } + return ( + + ); + }; + return PasswordComponent; + }; return [ { field: 'password', type: FormFieldType.Password, placeholder: t(passwordPlaceholderKey), + dataTestId: 'signup-password-input', rules: [ { type: RuleType.Required, @@ -51,7 +73,7 @@ const getPasswordFields = ( }), }, ], - customRender: PasswordInput, + customRender: renderPasswordField(false), errorClassName: styles.errorText, containerClassName: styles.inputContainer, }, @@ -59,6 +81,7 @@ const getPasswordFields = ( field: 'confirmPassword', type: FormFieldType.Password, placeholder: t(confirmPasswordPlaceholderKey), + dataTestId: 'signup-confirm-password-input', rules: [ { type: RuleType.Required, @@ -66,7 +89,7 @@ const getPasswordFields = ( errorMessage: t('errors.required', { fieldName: t('common:form.confirm-password') }), }, ], - customRender: ConfirmPasswordField, + customRender: renderPasswordField(true), errorClassName: styles.errorText, containerClassName: styles.inputContainer, }, diff --git a/src/components/Login/SignUpForm/PasswordInput.module.scss b/src/components/Login/SignUpForm/PasswordInput.module.scss index 77f1e2c0e5..f4eefa7043 100644 --- a/src/components/Login/SignUpForm/PasswordInput.module.scss +++ b/src/components/Login/SignUpForm/PasswordInput.module.scss @@ -1,60 +1,75 @@ -$container-height: 52px; +$container-height: calc(var(--spacing-medium2-px) + var(--spacing-large-px)); .passwordInputContainer { - position: relative; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - padding-inline: var(--spacing-small-px); - height: $container-height; - box-sizing: border-box; - border-radius: var(--border-radius-medium-px); - border: 1px solid var(--color-border-gray-lighter); - background-color: var(--color-background-default); - transition: border-color 0.2s ease; - &:hover, - &:focus, - &.hasValue { - border: 1px solid var(--color-success-medium); - } + position: relative; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-inline: var(--spacing-small-px); + height: $container-height; + box-sizing: border-box; + border-radius: var(--border-radius-medium-px); + border: var(--spacing-hairline-px) solid var(--color-border-gray-lighter); + background-color: var(--color-background-default); + transition: border-color 0.2s ease; + &:hover:not(.disabled), + &:focus:not(.disabled), + &.hasValue { + border: var(--spacing-hairline-px) solid var(--color-success-medium); + } + &:has(input:disabled) { input { - &::placeholder { - color: var(--color-border-gray-light) !important; - } + cursor: not-allowed; + } + } + + input { + &::placeholder { + color: var(--color-border-gray-light) !important; } + } } .toggleButton { - position: relative; - background: none; - border: none; - padding: 4px; - margin-top: var(--spacing-xxsmall-px); - cursor: pointer; - color: var(--color-text-faded); - padding-inline: var(--spacing-xxsmall); - display: flex; - align-items: center; - justify-content: center; - - &:hover { - color: var(--color-text-default); - } + position: relative; + background: none; + border: none; + padding: calc(var(--spacing-xxxsmall-px) + var(--spacing-hairline-px)); + margin-top: var(--spacing-xxsmall-px); + cursor: pointer; + color: var(--color-text-faded); + padding-inline: var(--spacing-xxsmall); + display: flex; + align-items: center; + justify-content: center; - &:focus { - outline: none; - color: var(--color-text-default); - } + &:hover { + color: var(--color-text-default); + } + &:hover { + color: var(--color-text-default); + } - svg { - width: var(--spacing-medium2-px); - height: var(--spacing-medium2-px); - } + &:focus { + outline: none; + color: var(--color-text-default); + } + + svg { + width: var(--spacing-medium2-px); + height: var(--spacing-medium2-px); + } + svg { + width: var(--spacing-medium2-px); + height: var(--spacing-medium2-px); + } } .icon { - width: var(--spacing-large); - height: var(--spacing-large); + width: var(--spacing-large); + height: var(--spacing-large); + width: var(--spacing-large); + height: var(--spacing-large); } diff --git a/src/components/Login/SignUpForm/PasswordInput.tsx b/src/components/Login/SignUpForm/PasswordInput.tsx index b49150e339..beec5b2003 100644 --- a/src/components/Login/SignUpForm/PasswordInput.tsx +++ b/src/components/Login/SignUpForm/PasswordInput.tsx @@ -1,42 +1,66 @@ import { FC, useState } from 'react'; import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; import styles from './PasswordInput.module.scss'; +import Input, { HtmlInputType } from '@/components/dls/Forms/Input'; import HideIcon from '@/icons/hide.svg'; import ShowIcon from '@/icons/show.svg'; interface Props { + id?: string; + label?: string; value: string; onChange: (value: string) => void; placeholder?: string; + containerClassName?: string; + isDisabled?: boolean; + dataTestId?: string; } -const PasswordInput: FC = ({ value = '', onChange, placeholder }) => { +const PasswordInput: FC = ({ + id = 'password-input', + label, + value = '', + onChange, + placeholder, + containerClassName, + isDisabled = false, + dataTestId, +}) => { + const { t } = useTranslation('login'); const [showPassword, setShowPassword] = useState(false); return ( -
- onChange(e.target.value)} - placeholder={placeholder} - /> - -
+ suffix={ + + } + /> ); }; diff --git a/src/components/Login/SignUpForm/PasswordValidation.module.scss b/src/components/Login/SignUpForm/PasswordValidation.module.scss index 4eee7edf13..73a5b66543 100644 --- a/src/components/Login/SignUpForm/PasswordValidation.module.scss +++ b/src/components/Login/SignUpForm/PasswordValidation.module.scss @@ -1,52 +1,43 @@ -@use "src/styles/theme"; +@use 'src/styles/theme'; .passwordValidation { - margin-inline-start: var(--spacing-small-px); - row-gap: var(--spacing-small-px); - display: flex; - flex-direction: column; + margin-block-start: var(--spacing-xxsmall-px); + margin-inline-start: var(--spacing-small-px); + row-gap: var(--spacing-small-px); + display: flex; + flex-direction: column; } .validationRule { - display: flex; - align-items: center; + display: flex; + align-items: center; + gap: var(--spacing-small-px); } .ruleText { - font-size: var(--font-size-small); - - @include theme.light { - color: var(--color-border-gray-dark); - } - - @include theme.sepia { - color: var(--color-border-gray-dark); - } + font-size: var(--font-size-small); + color: var(--color-text-faded-new); +} - @include theme.dark { - color: var(--color-text-white); - } +.ruleIcon { + width: var(--spacing-small2-px); + height: var(--spacing-small2-px); } .valid { - margin-inline-end: var(--spacing-xsmall-px); + .ruleIcon { + svg path, + svg g { + fill: var(--color-success-medium); + } + } } .invalid { - margin-inline-end: var(--spacing-small-px); - - @include theme.light { - color: var(--color-text-black); - filter: brightness(1); - } - - @include theme.sepia { - color: var(--color-text-default); - filter: brightness(1); - } - - @include theme.dark { - color: var(--color-text-inverse); - filter: brightness(0) invert(1); + .ruleIcon { + svg path, + svg g { + fill: var(--color-text-default-new); } + } } diff --git a/src/components/Login/SignUpForm/PasswordValidation.tsx b/src/components/Login/SignUpForm/PasswordValidation.tsx index b7f792849e..d98946bad7 100644 --- a/src/components/Login/SignUpForm/PasswordValidation.tsx +++ b/src/components/Login/SignUpForm/PasswordValidation.tsx @@ -1,13 +1,16 @@ import { FC } from 'react'; import classNames from 'classnames'; -import Image from 'next/image'; import useTranslation from 'next-translate/useTranslation'; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '../SignUpFormFields/consts'; import styles from './PasswordValidation.module.scss'; +import IconContainer from '@/dls/IconContainer/IconContainer'; +import CheckIcon from '@/icons/checkmark-icon.svg'; +import CloseIcon from '@/icons/close-icon.svg'; + interface PasswordRule { test: (value: string) => boolean; messageKey: string; @@ -62,7 +65,7 @@ const PasswordValidation: FC = ({ value = '' }) => { } return ( -
+
{rules.map((rule) => { const isValid = rule.test(value); return ( @@ -73,15 +76,9 @@ const PasswordValidation: FC = ({ value = '' }) => { [styles.invalid]: !isValid, })} > - {isValid : } /> {t(rule.messageKey)}
diff --git a/src/components/Login/SignUpFormFields/credentialFields.ts b/src/components/Login/SignUpFormFields/credentialFields.ts index 61afd95345..fcf6eb1701 100644 --- a/src/components/Login/SignUpFormFields/credentialFields.ts +++ b/src/components/Login/SignUpFormFields/credentialFields.ts @@ -14,6 +14,7 @@ export const getEmailField = (t: any): FormBuilderFormField => ({ field: 'email', type: FormFieldType.Text, placeholder: t('email-placeholder'), + dataTestId: 'signup-email-input', rules: [ { type: RuleType.Required, @@ -32,6 +33,7 @@ export const getUsernameField = (t: any): FormBuilderFormField => ({ field: 'username', type: FormFieldType.Text, placeholder: t('username-placeholder'), + dataTestId: 'signup-username-input', rules: [ { type: RuleType.Required, diff --git a/src/components/Login/SignUpFormFields/nameFields.ts b/src/components/Login/SignUpFormFields/nameFields.ts index cab473d46a..f66f60782e 100644 --- a/src/components/Login/SignUpFormFields/nameFields.ts +++ b/src/components/Login/SignUpFormFields/nameFields.ts @@ -13,6 +13,7 @@ export const getNameFields = (t: any): FormBuilderFormField[] => [ field: 'firstName', type: FormFieldType.Text, placeholder: t('first-name-placeholder'), + dataTestId: 'signup-first-name-input', rules: [ { type: RuleType.Required, @@ -46,6 +47,7 @@ export const getNameFields = (t: any): FormBuilderFormField[] => [ field: 'lastName', type: FormFieldType.Text, placeholder: t('last-name-placeholder'), + dataTestId: 'signup-last-name-input', rules: [ { type: RuleType.Required, diff --git a/src/components/Login/SignUpFormWithCustomRender.tsx b/src/components/Login/SignUpFormWithCustomRender.tsx index 747bfa8f02..7420393c96 100644 --- a/src/components/Login/SignUpFormWithCustomRender.tsx +++ b/src/components/Login/SignUpFormWithCustomRender.tsx @@ -23,6 +23,7 @@ const addCustomRenderToFormFields = ( {...props} id={field.field} htmlType={field.field === 'email' ? 'email' : 'text'} + dataTestId={props.dataTestId} /> ), errorClassName: styles.errorText, diff --git a/src/components/Login/SocialButtons.tsx b/src/components/Login/SocialButtons.tsx index ce79d3dbcc..4d91c6e58a 100644 --- a/src/components/Login/SocialButtons.tsx +++ b/src/components/Login/SocialButtons.tsx @@ -5,7 +5,7 @@ import useTranslation from 'next-translate/useTranslation'; import styles from './login.module.scss'; -import Button, { ButtonShape, ButtonVariant } from '@/dls/Button/Button'; +import Button, { ButtonShape, ButtonSize } from '@/dls/Button/Button'; import AppleIcon from '@/icons/apple.svg'; import FacebookIcon from '@/icons/facebook.svg'; import GoogleIcon from '@/icons/google.svg'; @@ -15,10 +15,9 @@ import AuthType from 'types/auth/AuthType'; interface Props { redirect?: string; - onEmailLoginClick: () => void; } -const SocialButtons: FC = ({ redirect, onEmailLoginClick }) => { +const SocialButtons: FC = ({ redirect }) => { const { t } = useTranslation('login'); const onSocialButtonClick = (type: AuthType) => { @@ -26,7 +25,7 @@ const SocialButtons: FC = ({ redirect, onEmailLoginClick }) => { }; return ( -
+
+ data-testid="google-login-button" + size={ButtonSize.Medium} + ariaLabel={t('continue-google')} + /> + + data-testid="facebook-login-button" + size={ButtonSize.Medium} + ariaLabel={t('continue-facebook')} + /> + - + data-testid="apple-login-button" + size={ButtonSize.Medium} + ariaLabel={t('continue-apple')} + />
); }; diff --git a/src/components/Login/VerificationCode/VerificationCodeBase.tsx b/src/components/Login/VerificationCode/VerificationCodeBase.tsx index afdf49f906..952ffb159d 100644 --- a/src/components/Login/VerificationCode/VerificationCodeBase.tsx +++ b/src/components/Login/VerificationCode/VerificationCodeBase.tsx @@ -90,7 +90,7 @@ const VerificationCodeBase: FC = ({ }; return ( -
+

{t(titleTranslationKey)}

diff --git a/src/components/Login/VerificationCode/VerificationCodeForm.tsx b/src/components/Login/VerificationCode/VerificationCodeForm.tsx index 0b7120c759..a30c9fdcb3 100644 --- a/src/components/Login/VerificationCode/VerificationCodeForm.tsx +++ b/src/components/Login/VerificationCode/VerificationCodeForm.tsx @@ -1,16 +1,20 @@ /* eslint-disable react-func/max-lines-per-function */ -import { FC } from 'react'; +import { FC, useContext } from 'react'; import { useRouter } from 'next/router'; +import { useDispatch } from 'react-redux'; import VerificationCodeBase from './VerificationCodeBase'; import AuthHeader from '@/components/Login/AuthHeader'; import styles from '@/components/Login/login.module.scss'; import useAuthRedirect from '@/hooks/auth/useAuthRedirect'; +import { persistCurrentSettings } from '@/redux/slices/defaultSettings'; import SignUpRequest from '@/types/auth/SignUpRequest'; import { signUp } from '@/utils/auth/authRequests'; +import { syncPreferencesFromServer } from '@/utils/auth/syncPreferencesFromServer'; import { logFormSubmission } from '@/utils/eventLogger'; +import { AudioPlayerMachineContext } from 'src/xstate/AudioPlayerMachineContext'; interface Props { email: string; @@ -30,6 +34,8 @@ const VerificationCodeForm: FC = ({ handleSubmit, }) => { const router = useRouter(); + const dispatch = useDispatch(); + const audioService = useContext(AudioPlayerMachineContext); const { redirectWithToken } = useAuthRedirect(); const handleSubmitCode = async (code: string): Promise => { @@ -45,12 +51,37 @@ const VerificationCodeForm: FC = ({ throw new Error(errors?.verificationCode || 'Invalid verification code'); } + try { + await dispatch(persistCurrentSettings()); + } catch (persistError) { + // eslint-disable-next-line no-console + console.error('Failed to persist current settings after verification', { + error: persistError, + timestamp: new Date().toISOString(), + }); + } + + let targetLocale = router.locale || 'en'; + try { + const { appliedLocale } = await syncPreferencesFromServer({ + locale: targetLocale, + dispatch, + audioService, + }); + if (appliedLocale) { + targetLocale = appliedLocale; + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to sync user preferences after verification', error); + } + // If successful, call onSuccess callback or redirect if (onSuccess) { onSuccess(); } else { // Default behavior: redirect back or to home - redirectWithToken((router.query.redirect as string) || '/', response?.token); + redirectWithToken((router.query.redirect as string) || '/', response?.token, targetLocale); } }; diff --git a/src/components/Login/login.module.scss b/src/components/Login/login.module.scss index 0f7bdb30e3..8e09b4b525 100644 --- a/src/components/Login/login.module.scss +++ b/src/components/Login/login.module.scss @@ -1,5 +1,6 @@ -@use "src/styles/theme"; -@use "src/styles/breakpoints"; +@use 'src/styles/theme'; +@use 'src/styles/breakpoints'; +@use 'src/styles/constants'; $container-width: 370px; @@ -19,17 +20,6 @@ $container-width: 370px; } } -.innerContainer { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - padding: var(--spacing-large); - background-color: var(--color-background-elevated); - border-radius: var(--border-radius-large); - box-shadow: var(--shadow-small); -} - .title { font-size: var(--font-size-normal-px); color: var(--color-text-default); @@ -41,69 +31,30 @@ $container-width: 370px; } } -.subtitle { - text-align: center; - margin-block-end: var(--spacing-xlarge); - color: var(--color-text-default); - max-width: 400px; - font-size: var(--font-size-xsmall-px); -} - -.inlineText { - display: inline; -} - .boldText { font-weight: var(--font-weight-bold); } -.serviceCard { - display: flex; - flex-direction: column; - align-items: center; - inline-size: 100%; -} - -.benefits { - display: flex; - flex-wrap: wrap; - justify-content: center; - inline-size: 100%; - gap: var(--spacing-small-px); - margin-block-start: var(--spacing-medium2-px); -} - -.benefit { - display: flex; - justify-content: center; -} - -.benefitContent { - display: flex; - align-items: center; - column-gap: calc(var(--spacing-xsmall-px) / 2); -} - -.benefitText { - text-align: center; - font-size: var(--font-size-xsmall-px); -} - .authButtons { display: flex; - flex-direction: column; + flex-direction: row; gap: var(--spacing-medium); width: 100%; + + & > * { + flex: 1; + } } .loginButton { width: 100%; + height: constants.$oauth-social-button-height; justify-content: center; padding: var(--spacing-medium); font-size: var(--font-size-normal); font-weight: var(--font-weight-bold); border-radius: var(--border-radius-medium-px); - border: 1px solid var(--color-border-gray-dark); + border: 1px solid var(--color-border-gray-lighter); background-color: var(--color-background-default); color: var(--color-text-default); @@ -111,6 +62,10 @@ $container-width: 370px; background-color: var(--color-background-elevated); } + span { + margin: 0; + } + svg { width: 24px !important; height: 24px !important; @@ -166,14 +121,6 @@ $container-width: 370px; border-radius: var(--border-radius-rounded); } -.resendEmailSection { - margin-block-start: var(--spacing-medium); - padding-block-start: var(--spacing-medium); - border-block-start: 1px solid var(--color-background-elevated); - display: flex; - flex-direction: column; -} - .resendButton { margin-block-start: var(--spacing-small); } @@ -185,10 +132,6 @@ $container-width: 370px; margin-block-start: var(--spacing-medium2-px); } -.bold { - font-weight: var(--font-weight-bold); -} - .authContainer { width: 100%; display: flex; @@ -196,14 +139,6 @@ $container-width: 370px; font-family: var(--font-family-figtree); } -.authLogos { - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-mega); - padding: var(--spacing-large); -} - .authTabs { width: 100%; display: flex; @@ -223,7 +158,6 @@ $container-width: 370px; text-align: center; color: var(--color-text-default); margin-block: calc(var(--spacing-medium2-px) * 3); - font-family: Roboto, sans-serif !important; } .description { @@ -289,6 +223,10 @@ $container-width: 370px; } } +.smallMarginTop { + margin-block-start: var(--spacing-small-px); +} + .inputContainer { display: flex; flex-direction: column; @@ -305,7 +243,7 @@ $container-width: 370px; color: var(--color-success-medium); text-decoration: none; font-size: var(--font-size-small); - text-align: start; + text-align: end; font-weight: var(--font-weight-bold); margin-block-end: var(--spacing-small-px); @@ -337,23 +275,6 @@ $container-width: 370px; } } -.logos { - @include theme.light { - color: var(--color-text-black); - filter: brightness(1); - } - - @include theme.sepia { - color: var(--color-text-default); - filter: brightness(1); - } - - @include theme.dark { - color: var(--color-text-inverse); - filter: brightness(0) invert(1); - } -} - .container { display: flex; flex-direction: column; @@ -365,90 +286,72 @@ $container-width: 370px; margin: 0 auto; } -.form { +.orSeparator { display: flex; - flex-direction: column; - gap: var(--spacing-medium); + align-items: center; width: 100%; + margin-block: var(--spacing-large-px); + gap: var(--spacing-small); } -.formField { - width: 100%; - padding: var(--spacing-medium); - font-size: var(--font-size-normal); - border: 1px solid var(--color-borders-hairline); - border-radius: var(--border-radius-default); - background-color: var(--color-background-elevated); - color: var(--color-text-default); +.orLine { + flex: 1; + border: none; + border-top: 1px solid var(--color-border-gray-lighter); + margin: 0; +} - &::placeholder { - color: var(--color-text-faded); - } +.orText { + font-size: var(--font-size-small); + color: var(--color-border-gray-medium); + font-weight: var(--font-weight-normal); + text-transform: uppercase; + padding-inline: var(--spacing-small); +} - &:focus { - outline: none; - border-color: var(--color-primary-medium); - } +.authHeader { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; } -.comingSoon { - text-align: center; +.welcomeTitle { + font-size: var(--font-size-large2); + font-weight: var(--font-weight-normal); color: var(--color-text-default); - font-size: var(--font-size-medium); - margin-top: var(--spacing-large); + text-align: center; + margin: 0; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; } -.servicesContainer { +.subtitleAndLogoGroup { display: flex; flex-direction: column; align-items: center; + gap: var(--spacing-xsmall); + margin-block-start: var(--spacing-xxlarge-px); + margin-block-end: var(--spacing-large-px); width: 100%; - margin-block-start: var(--spacing-large-px); - border: 1px solid var(--color-border-gray-medium); - border-radius: var(--border-radius-large-px); - padding: var(--spacing-medium2-px); - box-sizing: border-box; -} - -.divider { - width: 100%; - border: none; - border-top: 1px solid var(--color-borders-hairline); - margin: auto; } -.serviceDivider { - width: 100%; - border: none; - border-top: 1px solid var(--color-borders-hairline); - margin: auto; - margin-block: var(--spacing-medium-px); +.welcomeSubtitle { + font-size: var(--font-size-small); + color: var(--color-text-default); + text-align: center; + margin: 0; + font-weight: var(--font-weight-normal); } -.reflectLogos { +.quranLogoContainer { display: flex; align-items: center; - gap: var(--spacing-xsmall-px); + justify-content: center; } -.reflectLogo { +.quranLogo { width: auto; -} - -.scaleDown { - scale: 0.85; -} - -.loginCta { - font-size: var(--font-size-xsmall-px); - margin-top: var(--spacing-medium2-px); - margin-bottom: var(--spacing-medium-px); -} - -.emailButton { - color: var(--color-qdc-blue); - border: 1px solid var(--color-qdc-blue); - &:hover { - color: var(--color-qdc-blue); - } + height: 18px; } diff --git a/src/components/MarkdownEditor/MarkdownEditor.module.scss b/src/components/MarkdownEditor/MarkdownEditor.module.scss index 7b25be7f74..a9135bf16a 100644 --- a/src/components/MarkdownEditor/MarkdownEditor.module.scss +++ b/src/components/MarkdownEditor/MarkdownEditor.module.scss @@ -1,93 +1,94 @@ .editor { - li, p { - font-size: var(--font-size-large); - line-height: 160%; - } - // min-height: calc(var(--spacing-mega) * 10); - em, - strong { - all: revert; - } - - ul, - ol { - padding: 0 var(--spacing-medium); - } - ul { - list-style: initial !important; - } - ol { - list-style-type: decimal !important; - } + li, + p { + font-size: var(--font-size-large); + line-height: 160%; + } + // min-height: calc(var(--spacing-mega) * 10); + em, + strong { + all: revert; + } - li { - padding-block: var(--spacing-micro); - } - - h1, - h2, - h3, - h4, - h5, - h6 { - all: revert; - } + ul, + ol { + padding: 0 var(--spacing-medium); + } + ul { + list-style: initial !important; + } + ol { + list-style-type: decimal !important; + } - hr { - margin: var(--spacing-medium) 0; - border: none; - border-block-start: 2px solid var(--color-background-inverse); - } - blockquote { - background-color: var(--color-success-faint); - display: block; - padding: var(--spacing-mega); - margin-block: var(--spacing-large); - position: relative; - border-radius: var(--border-radius-rounded); + li { + padding-block: var(--spacing-micro); + } - /*Font*/ - line-height: var(--line-height-jumbo); - color: var(--color-text-default); + h1, + h2, + h3, + h4, + h5, + h6 { + all: revert; + } - &::before { - content: "\201C"; /*Unicode for Left Double Quote*/ + hr { + margin: var(--spacing-medium) 0; + border: none; + border-block-start: 2px solid var(--color-background-inverse); + } + blockquote { + background-color: var(--color-success-faint); + display: block; + padding: var(--spacing-mega); + margin-block: var(--spacing-large); + position: relative; + border-radius: var(--border-radius-rounded); - /*Font*/ - font-family: Georgia, serif; - font-size: calc(1.6 * var(--font-size-jumbo)); - font-weight: bold; - color: var(--color-text-default); + /*Font*/ + line-height: var(--line-height-jumbo); + color: var(--color-text-default); - /*Positioning*/ - position: absolute; - inset-inline-start: var(--spacing-medium); - inset-block-start: var(--spacing-medium); - } + &::before { + content: '\201C'; /*Unicode for Left Double Quote*/ - &::after { - /*Reset to make sure*/ - content: ""; - } + /*Font*/ + font-family: Georgia, serif; + font-size: calc(1.6 * var(--font-size-jumbo)); + font-weight: bold; + color: var(--color-text-default); - p { - font-size: var(--font-size-xlarge); - font-weight: var(--font-weight-semibold); - line-height: var(--line-height-xlarge); - text-align: center; - } + /*Positioning*/ + position: absolute; + inset-inline-start: var(--spacing-medium); + inset-block-start: var(--spacing-medium); } - img { - display: block; - margin: auto; - max-inline-size: 100%; + + &::after { + /*Reset to make sure*/ + content: ''; } - iframe { - display: block; // Makes iframe a block element, ensuring it's on a new line - margin: var(--spacing-medium) auto; // Centers the iframe and adds vertical spacing - max-inline-size: 100%; + + p { + font-size: var(--font-size-xlarge); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-xlarge); + text-align: center; } + } + img { + display: block; + margin: auto; + max-inline-size: 100%; + } + iframe { + display: block; // Makes iframe a block element, ensuring it's on a new line + margin: var(--spacing-medium) auto; // Centers the iframe and adds vertical spacing + max-inline-size: 100%; + } } .content { - flex: 1 1 auto; + flex: 1 1 auto; } diff --git a/src/components/MediaMaker/Content/index.tsx b/src/components/MediaMaker/Content/index.tsx index dc4dfc4e62..3833e1430f 100644 --- a/src/components/MediaMaker/Content/index.tsx +++ b/src/components/MediaMaker/Content/index.tsx @@ -10,6 +10,7 @@ import { AbsoluteFill, Audio, Sequence, Video, staticFile } from 'remotion'; import styles from './MediaMakerContent.module.scss'; import useGetChaptersData from '@/hooks/useGetChaptersData'; +import Language from '@/types/Language'; import Alignment from '@/types/Media/Alignment'; import { Timestamp } from '@/types/Media/GenerateMediaFileRequest'; import Orientation from '@/types/Media/Orientation'; @@ -61,7 +62,7 @@ const MediaMakerContent: React.FC = ({ chapterEnglishName, isPlayer = false, }) => { - const chaptersDataArabic = useGetChaptersData('ar'); + const chaptersDataArabic = useGetChaptersData(Language.AR); const firstVerseTiming = audio?.verseTimings[0]; const startFrom = useMemo(() => { diff --git a/src/components/MediaMaker/MediaMaker.module.scss b/src/components/MediaMaker/MediaMaker.module.scss index e318fee946..07d2635ea3 100644 --- a/src/components/MediaMaker/MediaMaker.module.scss +++ b/src/components/MediaMaker/MediaMaker.module.scss @@ -55,7 +55,8 @@ $colorPickerBorderRadius: calc(var(--spacing-medium) * 3); max-width: 30% !important; @include breakpoints.smallerThanTablet { max-width: 90% !important; - margin-block-start: var(--banner-height); + // TODO: we should add the banner height here if it's shown + // margin-block-start: var(--banner-height); } } @@ -94,7 +95,8 @@ $colorPickerBorderRadius: calc(var(--spacing-medium) * 3); @include breakpoints.smallerThanDesktop { width: 90%; - margin-block-start: var(--banner-height); + // TODO: we should add the banner height here if it's shown + // margin-block-start: var(--banner-height); } } diff --git a/src/components/Navbar/Drawer/Drawer.module.scss b/src/components/Navbar/Drawer/Drawer.module.scss index 37784d8081..989d333c29 100644 --- a/src/components/Navbar/Drawer/Drawer.module.scss +++ b/src/components/Navbar/Drawer/Drawer.module.scss @@ -58,7 +58,7 @@ .bodyContainer { flex: 1; - margin-block-start: var(--navbar-container-height); + margin-block-start: var(--navbar-height); padding-block-start: var(--spacing-small); padding-inline-start: var(--spacing-small); padding-inline-end: var(--spacing-small); @@ -79,7 +79,7 @@ font-size: var(--font-size-large); width: 100%; position: fixed; - height: var(--navbar-container-height); + height: var(--navbar-height); border-block-end: 1px var(--color-borders-hairline) solid; display: flex; flex-direction: row; @@ -117,5 +117,5 @@ } .navbarInvisible { - transform: translate3d(0, 0 + var(--navbar-container-height), 0); + padding-block-start: var(--navbar-container-height); } diff --git a/src/components/Navbar/Drawer/index.tsx b/src/components/Navbar/Drawer/index.tsx index 865db07137..cca7866f94 100644 --- a/src/components/Navbar/Drawer/index.tsx +++ b/src/components/Navbar/Drawer/index.tsx @@ -19,7 +19,7 @@ import { setIsNavigationDrawerOpen, setIsSearchDrawerOpen, setIsSettingsDrawerOpen, - setIsVisible, + setLockVisibilityState, } from '@/redux/slices/navbar'; import { logEvent } from '@/utils/eventLogger'; @@ -35,6 +35,7 @@ export enum DrawerSide { } interface Props { + id?: string; type: DrawerType; side?: DrawerSide; header: ReactNode; @@ -86,6 +87,7 @@ enum ActionSource { } const Drawer: React.FC = ({ + id, type, side = DrawerSide.Right, header, @@ -125,18 +127,30 @@ const Drawer: React.FC = ({ ); useEffect(() => { - // Keep nav bar visible when drawer is open + // Lock navbar visibility state when drawer is open to prevent scroll-based changes + // Unlock when drawer is closed to restore normal scroll behavior if (isOpen) { - dispatch(setIsVisible(true)); + dispatch(setLockVisibilityState(true)); + } else { + dispatch(setLockVisibilityState(false)); } + }, [dispatch, isOpen]); + useEffect(() => { // Hide navbar after successful navigation - router.events.on('routeChangeComplete', () => { + const handleRouteChange = () => { if (isOpen && closeOnNavigation) { closeDrawer(ActionSource.Navigation); } - }); - }, [closeDrawer, dispatch, router.events, isNavbarVisible, isOpen, closeOnNavigation]); + }; + + router.events.on('routeChangeComplete', handleRouteChange); + + // Cleanup function to remove the event listener + return () => { + router.events.off('routeChangeComplete', handleRouteChange); + }; + }, [closeDrawer, router.events, isOpen, closeOnNavigation]); useOutsideClickDetector( drawerRef, @@ -149,6 +163,7 @@ const Drawer: React.FC = ({ const isSearchDrawer = type === DrawerType.Search; return (

= ({ [styles.noTransition]: type === DrawerType.Search && navbar.disableSearchDrawerTransition, })} ref={drawerRef} - id={type === DrawerType.Settings ? 'settings-drawer-container' : undefined} + id={id || (type === DrawerType.Settings ? 'settings-drawer-container' : undefined)} >
{ - const isUsingDefaultSettings = useSelector(selectIsUsingDefaultSettings); + const userHasCustomised = useSelector(selectUserHasCustomised); + const detectedCountry = useSelector(selectDetectedCountry); const dispatch = useDispatch(); const { t, lang } = useTranslation('common'); const toast = useToast(); @@ -58,16 +66,32 @@ const LanguageSelector = ({ * @param {string} newLocale */ const onChange = async (newLocale: string) => { + const loggedIn = isLoggedIn(); // if the user didn't change the settings and he is transitioning to a new locale, we want to apply the default settings of the new locale - if (isUsingDefaultSettings) { - dispatch(resetSettings(newLocale)); + if (!userHasCustomised) { + const preferenceCountry = getCountryCodeForPreferences(newLocale, detectedCountry); + const countryPreference = await getCountryLanguagePreference(newLocale, preferenceCountry); + if (countryPreference) { + await dispatch(setDefaultsFromCountryPreference({ countryPreference, locale: newLocale })); + if (loggedIn) { + try { + await dispatch(persistCurrentSettings()); + } catch (persistError) { + // eslint-disable-next-line no-console + console.error( + 'Failed to persist settings after applying defaults on language change', + persistError, + ); + } + } + } } logValueChange('locale', lang, newLocale); - await setLanguage(newLocale); setLocaleCookie(newLocale); + await setLanguage(newLocale); - if (isLoggedIn()) { + if (loggedIn) { addOrUpdateUserPreference( PreferenceGroup.LANGUAGE, newLocale, @@ -80,8 +104,8 @@ const LanguageSelector = ({ text: t('undo'), primary: true, onClick: async () => { - await setLanguage(newLocale); setLocaleCookie(newLocale); + await setLanguage(newLocale); }, }, { @@ -127,6 +151,7 @@ const LanguageSelector = ({ } tooltip={t('languages')} + data-testid="language-selector-button-footer" variant={ButtonVariant.Ghost} suffix={ @@ -141,6 +166,7 @@ const LanguageSelector = ({ tooltip={t('languages')} shape={ButtonShape.Circle} variant={ButtonVariant.Ghost} + data-testid="language-selector-button-navbar" ariaLabel={t('aria.select-lng')} > @@ -158,6 +184,7 @@ const LanguageSelector = ({ isSelected={option.value === lang} shouldCloseMenuAfterClick key={option.value} + dataTestId={`language-selector-item-${option.value}`} onClick={() => onChange(option.value)} > {option.label} diff --git a/src/components/Navbar/Logo/NavbarLogoWrapper.tsx b/src/components/Navbar/Logo/NavbarLogoWrapper.tsx index c89a819651..169466ce07 100644 --- a/src/components/Navbar/Logo/NavbarLogoWrapper.tsx +++ b/src/components/Navbar/Logo/NavbarLogoWrapper.tsx @@ -1,14 +1,24 @@ +import { useCallback } from 'react'; + import useTranslation from 'next-translate/useTranslation'; +import { useDispatch } from 'react-redux'; import styles from './NavbarLogoWrapper.module.scss'; import Link from '@/dls/Link/Link'; import QuranTextLogo from '@/icons/quran-text-logo.svg'; +import { setIsSidebarNavigationVisible } from '@/redux/slices/QuranReader/sidebarNavigation'; const NavbarLogoWrapper = () => { const { t } = useTranslation('common'); + const dispatch = useDispatch(); + const handleLogoClick = useCallback(() => { + // Ensure sidebar is closed when navigating home via the logo + dispatch(setIsSidebarNavigationVisible(false)); + }, [dispatch]); + return ( - + ); diff --git a/src/components/Navbar/Navbar.module.scss b/src/components/Navbar/Navbar.module.scss index 6cff48c4cb..d1f8c0a91e 100644 --- a/src/components/Navbar/Navbar.module.scss +++ b/src/components/Navbar/Navbar.module.scss @@ -1,5 +1,5 @@ -@use "src/styles/constants"; -@use "src/styles/breakpoints"; +@use 'src/styles/constants'; +@use 'src/styles/breakpoints'; .emptySpacePlaceholder { height: var(--navbar-container-height); @@ -20,25 +20,8 @@ // https://ptgamr.github.io/2014-09-13-translate3d-vs-translate-performance/ transform: translate3d(0, calc(-1 * var(--navbar-container-height)), 0); @include breakpoints.smallerThanTablet { - transform: translate3d(0, calc(-1 * (var(--navbar-container-height) + var(--banner-height))), 0); - } -} - -.donateButton { - color: var(--color-text-link-new); - --themed-bg: var(--color-success-faded); - --themed-border: var(--color-success-faded); - font-weight: var(--font-weight-semibold); - - &:hover { - --themed-bg: var(--color-success-faded); - --themed-border: var(--color-success-faded); - color: var(--color-text-link-new); - } - - svg { - path { - fill: var(--color-blue-buttons-and-icons); - } + // TODO: we should add the banner height here if it's shown + // transform: translate3d(0, calc(-1 * (var(--navbar-container-height) + var(--banner-height))), 0); + transform: translate3d(0, calc(-1 * (var(--navbar-container-height))), 0); } } diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 9346662950..b4deae40cd 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -7,18 +7,26 @@ import styles from './Navbar.module.scss'; import NavbarBody from './NavbarBody'; import { useOnboarding } from '@/components/Onboarding/OnboardingProvider'; +import useDebounceNavbarVisibility from '@/hooks/useDebounceNavbarVisibility'; +import { selectIsBannerVisible } from '@/redux/slices/banner'; import { selectNavbar } from '@/redux/slices/navbar'; const Navbar = () => { const { isActive } = useOnboarding(); const { isVisible: isNavbarVisible } = useSelector(selectNavbar, shallowEqual); - const showNavbar = isNavbarVisible || isActive; + const isBannerVisible = useSelector(selectIsBannerVisible); + // Use the shared hook to debounce navbar visibility changes + const showNavbar = useDebounceNavbarVisibility(isNavbarVisible, isActive); return ( <>
-
-
-
- <> - - - - - - <> + )} +
+
+
+ +
+
+ {isBannerVisible && ( +
+ +
+ )} +
+
+ {!isLoggedIn && } - - + + {shouldRenderSidebarNavigation && } + {isLoggedIn && } + + + + +
-
+ ); }; diff --git a/src/components/Navbar/NavigationDrawer/LanguageContainer.module.scss b/src/components/Navbar/NavigationDrawer/LanguageContainer.module.scss new file mode 100644 index 0000000000..40a2bec637 --- /dev/null +++ b/src/components/Navbar/NavigationDrawer/LanguageContainer.module.scss @@ -0,0 +1,60 @@ +@use 'src/styles/utility'; + +.languageContainer { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background-color: var(--color-background-elevated); + transform: translateX(100%); + transition: transform var(--transition-regular) ease-in-out; + z-index: var(--z-index-default); + + [dir='rtl'] & { + transform: translateX(-100%); + } + + &.show { + transform: translateX(0); + } +} + +.languageHeader { + display: flex; + align-items: center; + padding-block: var(--spacing-medium-px); + padding-inline: var(--spacing-medium2-px); +} + +.backButton { + gap: var(--spacing-xxsmall-px); +} + +.languageTitle { + font-size: var(--font-size-small-px); + font-weight: var(--font-weight-semibold); +} + +.languageList { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.languageItem { + justify-content: flex-start; + padding-inline: var(--spacing-xxlarge-px); + padding-block: var(--spacing-small-px); + width: 100%; + text-align: start; + + &:hover { + background: var(--color-background-alternative-faded); + } + + &.selected { + font-weight: var(--font-weight-semibold); + color: var(--color-success-medium); + } +} diff --git a/src/components/Navbar/NavigationDrawer/LanguageContainer.tsx b/src/components/Navbar/NavigationDrawer/LanguageContainer.tsx new file mode 100644 index 0000000000..2544e2bb30 --- /dev/null +++ b/src/components/Navbar/NavigationDrawer/LanguageContainer.tsx @@ -0,0 +1,184 @@ +import React from 'react'; + +import classNames from 'classnames'; +import setLanguage from 'next-translate/setLanguage'; +import useTranslation from 'next-translate/useTranslation'; +import { useDispatch, useSelector } from 'react-redux'; + +import styles from './LanguageContainer.module.scss'; + +import { getCountryLanguagePreference } from '@/api'; +import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import { ToastStatus, useToast } from '@/dls/Toast/Toast'; +import IconArrowLeft from '@/icons/arrow-left.svg'; +import { logError } from '@/lib/newrelic'; +import { + persistCurrentSettings, + selectDetectedCountry, + selectUserHasCustomised, + setDefaultsFromCountryPreference, +} from '@/redux/slices/defaultSettings'; +import { addOrUpdateUserPreference } from '@/utils/auth/api'; +import { isLoggedIn } from '@/utils/auth/login'; +import { setLocaleCookie } from '@/utils/cookies'; +import { logValueChange } from '@/utils/eventLogger'; +import { getLocaleName } from '@/utils/locale'; +import { getCountryCodeForPreferences } from '@/utils/serverSideLanguageDetection'; +import i18nConfig from 'i18n.json'; +import PreferenceGroup from 'types/auth/PreferenceGroup'; + +const { locales } = i18nConfig; + +interface LanguageContainerProps extends React.HTMLAttributes { + show: boolean; + onBack: () => void; +} + +const LanguageContainer: React.FC = ({ show, onBack, ...props }) => { + const { t, lang } = useTranslation('common'); + const userHasCustomised = useSelector(selectUserHasCustomised); + const detectedCountry = useSelector(selectDetectedCountry); + const dispatch = useDispatch(); + const toast = useToast(); + + const handleLanguagePersistError = () => { + toast(t('error.pref-persist-fail'), { + status: ToastStatus.Warning, + actions: [ + { + text: t('undo'), + primary: true, + onClick: async () => { + await setLanguage(lang); + setLocaleCookie(lang); + }, + }, + { + text: t('continue'), + primary: false, + onClick: () => { + // do nothing + }, + }, + ], + }); + }; + + /** + * Apply QDC defaults for new language (translations, tafsir, mushaf, wbw, etc.). + * Uses dynamic API defaults. + * Fails gracefully - UI language will still switch if defaults fail. + */ + const applyDefaultsForNewLanguage = async (newLocale: string, loggedIn: boolean) => { + try { + const preferenceCountry = getCountryCodeForPreferences(newLocale, detectedCountry); + const countryPreference = await getCountryLanguagePreference(newLocale, preferenceCountry); + + // Apply dynamic defaults from QDC API + await dispatch(setDefaultsFromCountryPreference({ countryPreference, locale: newLocale })); + + // Persist the newly applied defaults if user is logged in + if (loggedIn) { + await dispatch(persistCurrentSettings()); + } + } catch (error) { + // Don't block locale change if defaults fail - UI language should still switch + logError('Failed to apply QDC defaults on language change', error); + } + }; + + /** + * Persist the language preference to server for logged-in users. + */ + const persistLanguagePreference = (newLocale: string, loggedIn: boolean) => { + if (loggedIn) { + addOrUpdateUserPreference( + PreferenceGroup.LANGUAGE, + newLocale, + PreferenceGroup.LANGUAGE, + ).catch(handleLanguagePersistError); + } + }; + + /** + * Handle language change with conditional defaults application. + * + * If user has NOT customised: apply QDC defaults for new locale + * If user HAS customised: only switch UI locale, keep preferences intact + */ + const onLanguageChange = async (newLocale: string) => { + if (newLocale === lang) { + onBack(); + return; + } + + try { + const loggedIn = isLoggedIn(); + + // Apply defaults only if user hasn't customised their preferences + if (!userHasCustomised) { + await applyDefaultsForNewLanguage(newLocale, loggedIn); + } + + logValueChange('locale', lang, newLocale); + await setLanguage(newLocale); + setLocaleCookie(newLocale); + persistLanguagePreference(newLocale, loggedIn); + onBack(); + } catch (error) { + toast(t('error.language-change-failed'), { status: ToastStatus.Error }); + // Log the error to aid debugging of language change failures + logError('Language change failed', error); + } + }; + + return ( +
+
+ +
+
+ {locales.map((locale) => ( + + ))} +
+
+ ); +}; + +export default LanguageContainer; diff --git a/src/components/Navbar/NavigationDrawer/LinkContainer.tsx b/src/components/Navbar/NavigationDrawer/LinkContainer.tsx index e714ecfad5..80afada587 100644 --- a/src/components/Navbar/NavigationDrawer/LinkContainer.tsx +++ b/src/components/Navbar/NavigationDrawer/LinkContainer.tsx @@ -5,16 +5,27 @@ import styles from './LinkContainer.module.scss'; import Link from '@/dls/Link/Link'; type LinkContainerProps = { + shouldKeepStyleWithoutHrefOnHover?: boolean; href?: string; isExternalLink?: boolean; children: React.ReactNode; onClick?: () => void; }; -const LinkContainer = ({ href, isExternalLink, children, onClick }: LinkContainerProps) => { +const LinkContainer = ({ + shouldKeepStyleWithoutHrefOnHover = false, + href, + isExternalLink, + children, + onClick, +}: LinkContainerProps) => { if (!href) { + if (shouldKeepStyleWithoutHrefOnHover) { + return
{children}
; + } return <>{children}; } + return ( void; +} + +const MENUS: MenuItem[] = [ + { + title: 'developers', + icon: , + href: DEVELOPERS_URL, + eventName: 'navigation_drawer_developers', + }, + { + title: 'product-updates', + icon: , + href: PRODUCT_UPDATES_URL, + eventName: 'navigation_drawer_product_updates', + }, + { + title: 'feedback', + icon: , + href: EXTERNAL_ROUTES.FEEDBACK, + eventName: 'navigation_drawer_feedback', + isExternalLink: true, + }, + { + title: 'help', + icon: , + href: SUPPORT_URL, + eventName: 'navigation_drawer_help', + }, +]; + +const MoreMenuCollapsible: React.FC = ({ + headerClassName, + headerLeftClassName, + contentClassName, + itemTitleClassName, + onItemClick, +}) => { + const { t } = useTranslation('common'); + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + logEvent('navigation_drawer_more_menu_collapsed'); + return; + } + logEvent('navigation_drawer_more_menu_expanded'); + }; + + return ( + } + /> + } + suffix={} + shouldRotateSuffixOnToggle + shouldSuffixTrigger + onOpenChange={onOpenChange} + > + {({ isOpen }) => { + if (!isOpen) return null; + + return ( +
+ {MENUS.map((menu) => ( + onItemClick(menu.eventName)} + isExternalLink={menu.isExternalLink} + /> + ))} +
+ ); + }} +
+ ); +}; + +export default MoreMenuCollapsible; diff --git a/src/components/Navbar/NavigationDrawer/NavigationDrawer.module.scss b/src/components/Navbar/NavigationDrawer/NavigationDrawer.module.scss index 72d1217839..fda813f96c 100644 --- a/src/components/Navbar/NavigationDrawer/NavigationDrawer.module.scss +++ b/src/components/Navbar/NavigationDrawer/NavigationDrawer.module.scss @@ -1,9 +1,11 @@ -@use "src/styles/breakpoints"; -@use "src/styles/utility"; +@use 'src/styles/breakpoints'; +@use 'src/styles/utility'; .leftCTA { display: flex; margin-inline-start: var(--spacing-xsmall); + gap: var(--spacing-xsmall); + justify-content: space-between; } .rightCTA { @@ -25,4 +27,5 @@ .centerVertically { @include utility.center-vertically; + width: 100%; } diff --git a/src/components/Navbar/NavigationDrawer/NavigationDrawer.tsx b/src/components/Navbar/NavigationDrawer/NavigationDrawer.tsx index 0019441415..62ea25c8fb 100644 --- a/src/components/Navbar/NavigationDrawer/NavigationDrawer.tsx +++ b/src/components/Navbar/NavigationDrawer/NavigationDrawer.tsx @@ -6,6 +6,7 @@ import { shallowEqual, useSelector } from 'react-redux'; import Drawer, { DrawerSide, DrawerType } from '../Drawer'; import NavbarLogoWrapper from '../Logo/NavbarLogoWrapper'; +import ProfileAvatarButton from '../NavbarBody/ProfileAvatarButton'; import styles from './NavigationDrawer.module.scss'; import NavigationDrawerBodySkeleton from './NavigationDrawerBodySkeleton'; @@ -22,12 +23,14 @@ const NavigationDrawer = () => { return (
+
} diff --git a/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/NavigationDrawerBody.module.scss b/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/NavigationDrawerBody.module.scss index 4f9822fb2e..5d310d7d65 100644 --- a/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/NavigationDrawerBody.module.scss +++ b/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/NavigationDrawerBody.module.scss @@ -1,21 +1,89 @@ -@use "src/styles/utility"; +@use 'src/styles/utility'; .listItemsContainer { - padding-inline-start: var(--spacing-medium); - padding-inline-end: var(--spacing-medium); - padding-block-end: calc(5 * var(--spacing-mega)); + height: 100%; + position: relative; + overflow: hidden !important; + /* Fallback for browsers without dynamic viewport units */ + max-height: calc(100vh - var(--navbar-container-height)); + /* Prefer dynamic viewport units where supported */ + max-height: calc(100dvh - var(--navbar-container-height)); } -.subtitle { - @include utility.center-vertically; - font-size: var(--font-size-xsmall); - text-transform: uppercase; - font-weight: var(--font-weight-bold); - border-block-end: 1px var(--color-borders-hairline) solid; - min-height: calc(var(--spacing-mega) + var(--spacing-small)); +.mainListItems { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + overflow: hidden !important; + height: 100%; + min-height: 0; + + &.hide { + pointer-events: none; + } +} + +.listItems { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding-inline: var(--spacing-medium3-px); + padding-block: var(--spacing-medium2-px); + display: flex; + flex-direction: column; + gap: var(--spacing-large2-px); + user-select: none; } .projectsDesc { - font-size: var(--font-size-small); - margin-block-start: var(--spacing-small); + font-size: var(--font-size-xxsmall-px); + margin-block-end: var(--spacing-small-px); + padding-inline-start: var(--spacing-large-px); +} + +.accordionHeaderLeft { + gap: var(--spacing-xxsmall-px); + align-items: center; +} + +.accordionContent { + margin-block-start: var(--spacing-medium2-px); + display: flex; + flex-direction: column; + gap: var(--spacing-medium2-px); + > a { + padding-inline-start: var(--spacing-large-px); + } +} + +.accordionItemTitle { + font-weight: var(--font-weight-normal); +} + +.ctaContainer { + margin-inline: auto; + max-width: fit-content; + padding: var(--spacing-medium2-px) var(--spacing-medium3-px); +} + +.ctaTop { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-small-px); + margin-block-end: var(--spacing-small-px); +} + +.ctaDonateButton { + width: 100%; +} + +.languageTrigger { + width: 100%; + justify-content: center; + border: var(--spacing-hairline-px) solid var(--color-borders-hairline); + cursor: pointer; + + svg path { + stroke: var(--color-text-default); + } } diff --git a/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/index.tsx b/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/index.tsx index c0a70afbb0..f611407261 100644 --- a/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/index.tsx +++ b/src/components/Navbar/NavigationDrawer/NavigationDrawerBody/index.tsx @@ -1,106 +1,113 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; -import Trans from 'next-translate/Trans'; +import classNames from 'classnames'; +import dynamic from 'next/dynamic'; import useTranslation from 'next-translate/useTranslation'; -import NavigationDrawerItem from '../NavigationDrawerItem'; +import LanguageContainer from '../LanguageContainer'; +import NavigationDrawerList from '../NavigationDrawerList'; import styles from './NavigationDrawerBody.module.scss'; -import FundraisingBanner from '@/components/Fundraising/FundraisingBanner'; -import Link, { LinkVariant } from '@/dls/Link/Link'; -import IconDevelopers from '@/icons/developers.svg'; -import IconFeedback from '@/icons/feedback.svg'; -import IconHome from '@/icons/home.svg'; -import IconInfo from '@/icons/info.svg'; -import IconProductUpdates from '@/icons/product-updates.svg'; -import IconQ from '@/icons/Q_simple.svg'; -import QuranReflect from '@/icons/QR.svg'; -import IconQuestionMark from '@/icons/question-mark.svg'; -import IconRadio2 from '@/icons/radio-2.svg'; -import IconRadio from '@/icons/radio.svg'; +import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import Spinner from '@/dls/Spinner/Spinner'; +import IconDiamond from '@/icons/diamond.svg'; +import IconGlobe from '@/icons/globe.svg'; +import { makeDonatePageUrl } from '@/utils/apiPaths'; +import { logButtonClick, logEvent } from '@/utils/eventLogger'; +import { getLocaleName } from '@/utils/locale'; -const NavigationDrawerBody = () => { - const { t } = useTranslation('common'); - return ( -
- -

{t('menu')}

- } href="/" /> - } /> - } /> - } href="/about-us" /> - } href="/developers" /> - } - href="/product-updates" - /> - } - href="https://feedback.quran.com/" - isExternalLink - /> - } href="/support" /> -

{t('our-projects')}

-

- , - }} - /> -

+const ThemeSwitcher = dynamic(() => import('../ThemeSwitcher'), { + ssr: false, + loading: () => , +}); - } - href="https://quran.com" - isExternalLink - /> - } - href="https://play.google.com/store/apps/details?id=com.quran.labs.androidquran&hl=en&pli=1 " - isExternalLink - /> - } - href="https://apps.apple.com/us/app/quran-by-quran-com-%D9%82%D8%B1%D8%A2%D9%86/id1118663303 " - isExternalLink - /> - } - href="https://quranreflect.com/" - isExternalLink - /> - } - href="https://sunnah.com/" - isExternalLink - /> - } - href="https://nuqayah.com/" - isExternalLink - /> - } - href="https://legacy.quran.com" - isExternalLink - /> - } - href="https://corpus.quran.com" - isExternalLink +const EVENT_NAMES = { + NAV_DRAWER_LANGUAGE_OPEN: 'navigation_drawer_language_selector_open', + NAV_DRAWER_LANGUAGE_CLOSE: 'navigation_drawer_language_selector_close', + NAV_DRAWER_DONATE: 'navigation_drawer_donate', +} as const; + +const NavigationDrawerBody = (): JSX.Element => { + const { t, lang } = useTranslation('common'); + const [showLanguageContainer, setShowLanguageContainer] = useState(false); + const languageButtonRef = useRef(null); + + const onLanguageButtonClick = useCallback(() => { + setShowLanguageContainer(true); + logEvent(EVENT_NAMES.NAV_DRAWER_LANGUAGE_OPEN); + }, []); + + const onBackButtonClick = useCallback(() => { + setShowLanguageContainer(false); + logEvent(EVENT_NAMES.NAV_DRAWER_LANGUAGE_CLOSE); + }, []); + + const onDonateClick = useCallback(() => { + logButtonClick(EVENT_NAMES.NAV_DRAWER_DONATE); + }, []); + + useEffect(() => { + if (!showLanguageContainer && languageButtonRef.current) { + languageButtonRef.current.focus(); // restore + } + }, [showLanguageContainer]); + + return ( +
+ +
+
+ +
+
+
+ + +
+ +
+
); }; diff --git a/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.module.scss b/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.module.scss index 27e9b9eb11..3d40e30c1e 100644 --- a/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.module.scss +++ b/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.module.scss @@ -1,12 +1,7 @@ -@use "src/styles/utility"; +@use 'src/styles/utility'; .container { @include utility.center-vertically; - - min-height: calc(var(--spacing-mega) + var(--spacing-small)); - padding-inline-start: var(--spacing-micro); - padding-inline-end: var(--spacing-micro); - border-block-end: 1px var(--color-borders-hairline) solid; } .containerStale { @@ -21,10 +16,12 @@ } .itemContainer { display: flex; + align-items: center; } .titleContainer { margin-inline-start: var(--spacing-medium); - font-size: var(--font-size-normal); - line-height: normal; + font-size: var(--font-size-normal-px); + font-weight: var(--font-weight-semibold); + line-height: 0; } diff --git a/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.tsx b/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.tsx index 3ead56b0a3..5f1364f51b 100644 --- a/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.tsx +++ b/src/components/Navbar/NavigationDrawer/NavigationDrawerItem.tsx @@ -9,33 +9,42 @@ import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconCont import IconNorthEast from '@/icons/north_east.svg'; type NavigationDrawerItemProps = { + shouldKeepStyleWithoutHrefOnHover?: boolean; title?: string; icon?: React.ReactNode; isExternalLink?: boolean; href?: string; isStale?: boolean; + titleClassName?: string; onClick?: () => void; }; const NavigationDrawerItem = ({ + shouldKeepStyleWithoutHrefOnHover = false, title, icon, isExternalLink, href, + titleClassName, isStale = false, onClick, }: NavigationDrawerItemProps) => ( - +
- {title} + {title}
{isExternalLink && ( diff --git a/src/components/Navbar/NavigationDrawer/NavigationDrawerList.tsx b/src/components/Navbar/NavigationDrawer/NavigationDrawerList.tsx new file mode 100644 index 0000000000..c0346f9be2 --- /dev/null +++ b/src/components/Navbar/NavigationDrawer/NavigationDrawerList.tsx @@ -0,0 +1,122 @@ +import React, { useCallback } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { useDispatch } from 'react-redux'; + +import MoreMenuCollapsible from './MoreMenuCollapsible'; +import NavigationDrawerItem from './NavigationDrawerItem'; +import OurProjectsCollapsible from './OurProjectsCollapsible'; + +import useGetContinueReadingUrl from '@/hooks/useGetContinueReadingUrl'; +import IconAbout from '@/icons/about.svg'; +import IconBookmarkFilled from '@/icons/bookmark_filled.svg'; +import IconHeadphonesFilled from '@/icons/headphones-filled.svg'; +import IconHome from '@/icons/home.svg'; +import IconSchool from '@/icons/school.svg'; +import { setIsNavigationDrawerOpen } from '@/redux/slices/navbar'; +import { logButtonClick } from '@/utils/eventLogger'; +import { + ABOUT_US_URL, + getProfileNavigationUrl, + LEARNING_PLANS_URL, + RADIO_URL, + RECITERS_URL, +} from '@/utils/navigation'; + +interface NavigationDrawerListProps { + accordionHeaderClassName?: string; + accordionHeaderLeftClassName?: string; + accordionContentClassName?: string; + accordionItemTitleClassName?: string; + projectsDescClassName?: string; +} + +const NavigationDrawerList: React.FC = ({ + accordionHeaderClassName, + accordionHeaderLeftClassName, + accordionContentClassName, + accordionItemTitleClassName, + projectsDescClassName, +}) => { + const { t } = useTranslation('common'); + const dispatch = useDispatch(); + const continueReadingUrl = useGetContinueReadingUrl(); + + const ITEMS = [ + { + title: t('read'), + icon: , + href: continueReadingUrl, + eventName: 'navigation_drawer_read', + }, + { + title: t('learn'), + icon: , + href: LEARNING_PLANS_URL, + eventName: 'navigation_drawer_learn', + }, + { + title: t('my-quran'), + icon: , + href: getProfileNavigationUrl(), + eventName: 'navigation_drawer_my_quran', + }, + { + title: t('quran-radio'), + icon: , + href: RADIO_URL, + eventName: 'navigation_drawer_quran_radio', + }, + { + title: t('reciters'), + icon: , + href: RECITERS_URL, + eventName: 'navigation_drawer_reciters', + }, + { + title: t('about'), + icon: , + href: ABOUT_US_URL, + eventName: 'navigation_drawer_about', + }, + ]; + + const handleItemClick = useCallback( + (eventName: string) => { + dispatch(setIsNavigationDrawerOpen(false)); + logButtonClick(eventName); + }, + [dispatch], + ); + + return ( + <> + {ITEMS.map((item) => ( + handleItemClick(item.eventName)} + /> + ))} + + + + ); +}; + +export default NavigationDrawerList; diff --git a/src/components/Navbar/NavigationDrawer/OurProjectsCollapsible.tsx b/src/components/Navbar/NavigationDrawer/OurProjectsCollapsible.tsx new file mode 100644 index 0000000000..0309e0702c --- /dev/null +++ b/src/components/Navbar/NavigationDrawer/OurProjectsCollapsible.tsx @@ -0,0 +1,146 @@ +import React from 'react'; + +import Trans from 'next-translate/Trans'; +import useTranslation from 'next-translate/useTranslation'; + +import NavigationDrawerItem from './NavigationDrawerItem'; + +import Collapsible from '@/dls/Collapsible/Collapsible'; +import Link, { LinkVariant } from '@/dls/Link/Link'; +import IconArrowRight from '@/icons/arrow-right.svg'; +import IconCaretDown from '@/icons/caret-down.svg'; +import IconSquareMore from '@/icons/square-more.svg'; +import { logEvent } from '@/utils/eventLogger'; +import { EXTERNAL_ROUTES, QURAN_URL } from '@/utils/navigation'; + +interface ProjectItem { + title: string; + href: string; + eventName: string; +} + +interface OurProjectsCollapsibleProps { + onItemClick: (eventName: string) => void; + headerClassName?: string; + headerLeftClassName?: string; + contentClassName?: string; + itemTitleClassName?: string; + descriptionClassName?: string; +} + +const PROJECTS: ProjectItem[] = [ + { + title: 'Quran.com', + href: QURAN_URL, + eventName: 'navigation_drawer_project_quran_com', + }, + { + title: 'Quran For Android', + href: EXTERNAL_ROUTES.QURAN_ANDROID, + eventName: 'navigation_drawer_project_quran_android', + }, + { + title: 'Quran iOS', + href: EXTERNAL_ROUTES.QURAN_IOS, + eventName: 'navigation_drawer_project_quran_ios', + }, + { + title: 'QuranReflect.com', + href: EXTERNAL_ROUTES.QURAN_REFLECT, + eventName: 'navigation_drawer_project_quran_reflect', + }, + { + title: 'Sunnah.com', + href: EXTERNAL_ROUTES.SUNNAH, + eventName: 'navigation_drawer_project_sunnah', + }, + { + title: 'Nuqayah.com', + href: EXTERNAL_ROUTES.NUQAYAH, + eventName: 'navigation_drawer_project_nuqayah', + }, + { + title: 'Legacy.quran.com', + href: EXTERNAL_ROUTES.LEGACY_QURAN_COM, + eventName: 'navigation_drawer_project_legacy', + }, + { + title: 'Corpus.quran.com', + href: EXTERNAL_ROUTES.CORPUS_QURAN_COM, + eventName: 'navigation_drawer_project_corpus', + }, +]; + +const OurProjectsCollapsible: React.FC = ({ + onItemClick, + headerClassName, + headerLeftClassName, + contentClassName, + itemTitleClassName, + descriptionClassName, +}) => { + const { t } = useTranslation('common'); + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + logEvent('navigation_drawer_our_projects_collapsed'); + return; + } + logEvent('navigation_drawer_our_projects_expanded'); + }; + + return ( + } + /> + } + suffix={} + shouldRotateSuffixOnToggle + shouldSuffixTrigger + onOpenChange={onOpenChange} + > + {({ isOpen }) => { + if (!isOpen) return null; + + return ( +
+

+ + ), + }} + /> +

+ {PROJECTS.map((project) => ( + } + href={project.href} + isExternalLink + onClick={() => onItemClick(project.eventName)} + /> + ))} +
+ ); + }} +
+ ); +}; + +export default OurProjectsCollapsible; diff --git a/src/components/Navbar/NavigationDrawer/ThemeSwitcher.module.scss b/src/components/Navbar/NavigationDrawer/ThemeSwitcher.module.scss new file mode 100644 index 0000000000..7e9cba5bc5 --- /dev/null +++ b/src/components/Navbar/NavigationDrawer/ThemeSwitcher.module.scss @@ -0,0 +1,77 @@ +@use 'src/styles/theme'; + +.triggerContainer { + width: 100%; + justify-content: center; + border: var(--spacing-hairline-px) solid var(--color-borders-hairline); +} + +.popoverContent { + padding: var(--spacing-small-px); + min-width: unset; + max-width: calc(var(--spacing-large-px) * 8); + width: 100%; + box-shadow: var(--shadow-flat); + + @include theme.dark { + box-shadow: none; + border: var(--spacing-hairline-px) solid var(--color-borders-hairline); + } +} + +@mixin popoverItemHoverState { + background-color: var(--color-success-medium); + color: var(--color-text-inverse); + + svg { + color: var(--color-text-inverse); + } + + svg path { + stroke: var(--color-text-inverse); + } +} + +.popoverItem { + padding: var(--spacing-xsmall-px) var(--spacing-medium-px); + border-radius: var(--border-radius-pill); + text-align: center; + justify-content: center; + font-weight: var(--font-weight-semibold); + border: none; + background-color: transparent; + + &[data-state='checked'], + &.popoverItemSelected { + color: var(--color-success-medium); + background-color: transparent; + box-shadow: var(--shadow-flat); + + @include theme.dark { + border: var(--spacing-hairline-px) solid var(--color-borders-hairline); + } + + svg { + color: var(--color-success-medium); + } + svg path { + stroke: var(--color-success-medium); + } + } + + &:focus { + background-color: transparent; + color: inherit; + } + + &:hover, + &[data-state='checked']:hover, + &.popoverItemSelected:hover, + &:focus:hover { + @include popoverItemHoverState; + } + + svg path { + stroke: var(--color-text-default); + } +} diff --git a/src/components/Navbar/NavigationDrawer/ThemeSwitcher.tsx b/src/components/Navbar/NavigationDrawer/ThemeSwitcher.tsx new file mode 100644 index 0000000000..61e10aa663 --- /dev/null +++ b/src/components/Navbar/NavigationDrawer/ThemeSwitcher.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; + +import styles from './ThemeSwitcher.module.scss'; + +import { themeIcons } from '@/components/Navbar/SettingsDrawer/ThemeSection'; +import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import PopoverMenu, { PopoverMenuExpandDirection } from '@/dls/PopoverMenu/PopoverMenu'; +import Spinner from '@/dls/Spinner/Spinner'; +import usePersistPreferenceGroup from '@/hooks/auth/usePersistPreferenceGroup'; +import { resetLoadedFontFaces } from '@/redux/slices/QuranReader/font-faces'; +import { selectTheme, setTheme } from '@/redux/slices/theme'; +import ThemeType from '@/redux/types/ThemeType'; +import PreferenceGroup from '@/types/auth/PreferenceGroup'; +import { logEvent, logValueChange } from '@/utils/eventLogger'; + +const ThemeSwitcher = () => { + const { t } = useTranslation('common'); + const { + actions: { onSettingsChange }, + isLoading, + } = usePersistPreferenceGroup(); + const dispatch = useDispatch(); + const theme = useSelector(selectTheme, shallowEqual); + + const themes = Object.values(ThemeType).map((themeValue) => ({ + label: t(`themes.${themeValue}`), + value: themeValue, + })); + + const onOpenChange = (open: boolean) => { + if (open) { + logEvent('navigation_drawer_theme_selector_open'); + return; + } + logEvent('navigation_drawer_theme_selector_close'); + }; + + const onThemeSelected = (value: ThemeType) => { + logValueChange('theme', theme.type, value); + onSettingsChange('type', value, setTheme(value), setTheme(theme.type), PreferenceGroup.THEME); + if (value !== theme.type) { + // reset the loaded Fonts when we switch to a different theme + dispatch(resetLoadedFontFaces()); + } + }; + + return ( + : themeIcons[theme.type]} + variant={ButtonVariant.Ghost} + size={ButtonSize.Small} + shape={ButtonShape.Pill} + > + {t('change-theme')} + + } + > + {themes.map((option, index) => ( + + onThemeSelected(option.value)} + className={classNames(styles.popoverItem, { + [styles.popoverItemSelected]: option.value === theme.type, + })} + icon={themeIcons[option.value]} + > + {option.label} + + {index < themes.length - 1 && } + + ))} + + ); +}; + +export default ThemeSwitcher; diff --git a/src/components/Navbar/SearchDrawer/Header/index.tsx b/src/components/Navbar/SearchDrawer/Header/index.tsx index b42e8a8ca2..0cfba21ebf 100644 --- a/src/components/Navbar/SearchDrawer/Header/index.tsx +++ b/src/components/Navbar/SearchDrawer/Header/index.tsx @@ -72,7 +72,7 @@ const Header: React.FC = ({ return ( <> -
+
{ return ( { /> } > -
- {isOpen && ( - - )} -
+ {isOpen && ( + + )}
); }; diff --git a/src/components/Navbar/SettingsDrawer/CheckboxChip.module.scss b/src/components/Navbar/SettingsDrawer/CheckboxChip.module.scss new file mode 100644 index 0000000000..fad183258f --- /dev/null +++ b/src/components/Navbar/SettingsDrawer/CheckboxChip.module.scss @@ -0,0 +1,43 @@ +.hiddenCheckbox { + position: absolute; + width: var(--spacing-hairline-px); + height: var(--spacing-hairline-px); + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-xxsmall-px); + padding-block-start: var(--spacing-xxsmall-px); + padding-block-end: var(--spacing-xxsmall-px); + padding-inline-start: var(--spacing-medium-px); + padding-inline-end: var(--spacing-medium-px); + background-color: var(--color-background-alternative-faint); + border-radius: var(--border-radius-pill); + color: var(--color-text-faded-new); + cursor: pointer; +} + +.chipSelected { + background-color: var(--color-text-default-new); + color: var(--color-text-inverse); +} + +.checkIcon { + display: inline-flex; + + svg { + width: var(--spacing-medium1-5-px); + height: var(--spacing-medium1-5-px); + } +} + +.label { + font-size: var(--font-size-xsmall-px); + font-weight: var(--font-weight-semibold); +} \ No newline at end of file diff --git a/src/components/Navbar/SettingsDrawer/CheckboxChip.tsx b/src/components/Navbar/SettingsDrawer/CheckboxChip.tsx new file mode 100644 index 0000000000..a109a82e32 --- /dev/null +++ b/src/components/Navbar/SettingsDrawer/CheckboxChip.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames'; + +import styles from './CheckboxChip.module.scss'; + +import CheckIcon from '@/icons/check.svg'; + +interface CheckboxChipProps { + checked: boolean; + label: string; + id: string; + name: string; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +const CheckboxChip = ({ + checked, + label, + id, + name, + onChange, + disabled, +}: CheckboxChipProps): JSX.Element => { + return ( + + ); +}; + +export default CheckboxChip; diff --git a/src/components/Navbar/SettingsDrawer/ResetButton.tsx b/src/components/Navbar/SettingsDrawer/ResetButton.tsx index 25af157135..453ec6dd7f 100644 --- a/src/components/Navbar/SettingsDrawer/ResetButton.tsx +++ b/src/components/Navbar/SettingsDrawer/ResetButton.tsx @@ -1,6 +1,3 @@ -/* eslint-disable react-func/max-lines-per-function */ -import { useContext } from 'react'; - import { unwrapResult } from '@reduxjs/toolkit'; import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; @@ -10,23 +7,19 @@ import styles from './ResetButton.module.scss'; import Button from '@/dls/Button/Button'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; -import resetSettings from '@/redux/actions/reset-settings'; -import { DEFAULT_XSTATE_INITIAL_STATE } from '@/redux/defaultSettings/defaultSettings'; -import { persistDefaultSettings } from '@/redux/slices/defaultSettings'; +import { logErrorToSentry } from '@/lib/sentry'; +import { persistCurrentSettings, resetDefaultSettings } from '@/redux/slices/defaultSettings'; import { isLoggedIn } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; -import { AudioPlayerMachineContext } from 'src/xstate/AudioPlayerMachineContext'; import QueryParam from 'types/QueryParam'; -// reset button will dispatch a `reset` action -// reducers will listen to this action -// for example, check slices/theme.ts. it has extra reducer that listens to `reset` action +// Reset button will dispatch the new resetDefaultSettings thunk +// which re-detects locale and fetches country preferences const ResetButton = () => { const dispatch = useDispatch(); const router = useRouter(); const { t, lang } = useTranslation('common'); const toast = useToast(); - const audioService = useContext(AudioPlayerMachineContext); const cleanupUrlAndShowSuccess = () => { const queryParams = [ @@ -43,34 +36,28 @@ const ResetButton = () => { toast(t('settings.reset-notif'), { status: ToastStatus.Success }); }; - const resetAndSetInitialState = () => { - dispatch(resetSettings(lang)); - audioService.send({ - type: 'SET_INITIAL_CONTEXT', - ...DEFAULT_XSTATE_INITIAL_STATE, - }); - }; - const onResetSettingsClicked = async () => { logButtonClick('reset_settings'); - if (isLoggedIn()) { - try { - await dispatch(persistDefaultSettings(lang)).then(unwrapResult); - resetAndSetInitialState(); - cleanupUrlAndShowSuccess(); - } catch { - toast(t('error.general'), { status: ToastStatus.Error }); + try { + await dispatch(resetDefaultSettings(lang)).then(unwrapResult); + + // If logged in, persist the new defaults to the server + if (isLoggedIn()) { + await dispatch(persistCurrentSettings()).then(unwrapResult); } - } else { - resetAndSetInitialState(); cleanupUrlAndShowSuccess(); + } catch (e) { + logErrorToSentry(e); + toast(t('error.general'), { status: ToastStatus.Error }); } }; return ( <>
- +
); diff --git a/src/components/Navbar/SettingsDrawer/SearchSelectionBody.module.scss b/src/components/Navbar/SettingsDrawer/SearchSelectionBody.module.scss index a1596538a4..230fbc3d3b 100644 --- a/src/components/Navbar/SettingsDrawer/SearchSelectionBody.module.scss +++ b/src/components/Navbar/SettingsDrawer/SearchSelectionBody.module.scss @@ -15,6 +15,14 @@ margin-block-end: var(--spacing-xsmall); display: flex; align-items: center; + gap: var(--spacing-xxsmall-px); +} + +.infoIcon { + width: var(--spacing-medium-px); + height: var(--spacing-medium-px); + cursor: pointer; + flex-shrink: 0; } .group { @@ -37,4 +45,4 @@ .input { width: 95%; -} +} \ No newline at end of file diff --git a/src/components/Navbar/SettingsDrawer/SettingsDrawer.tsx b/src/components/Navbar/SettingsDrawer/SettingsDrawer.tsx index e419e6598c..896539a772 100644 --- a/src/components/Navbar/SettingsDrawer/SettingsDrawer.tsx +++ b/src/components/Navbar/SettingsDrawer/SettingsDrawer.tsx @@ -60,18 +60,21 @@ const SettingsDrawer = () => { return ( - {isSettingsDrawerOpen && settingsView === SettingsView.Body && } - {isSettingsDrawerOpen && settingsView === SettingsView.Translation && ( - + {isSettingsDrawerOpen && ( +
+ {settingsView === SettingsView.Body && } + {settingsView === SettingsView.Translation && } + {settingsView === SettingsView.Reciter && } + {settingsView === SettingsView.Tafsir && } +
)} - {isSettingsDrawerOpen && settingsView === SettingsView.Reciter && } - {isSettingsDrawerOpen && settingsView === SettingsView.Tafsir && }
); }; diff --git a/src/components/Navbar/SettingsDrawer/TranslationSelectionBody.tsx b/src/components/Navbar/SettingsDrawer/TranslationSelectionBody.tsx index 5790a8b9f6..06aea31c5f 100644 --- a/src/components/Navbar/SettingsDrawer/TranslationSelectionBody.tsx +++ b/src/components/Navbar/SettingsDrawer/TranslationSelectionBody.tsx @@ -13,9 +13,13 @@ import styles from './SearchSelectionBody.module.scss'; import DataFetcher from '@/components/DataFetcher'; import Checkbox from '@/dls/Forms/Checkbox/Checkbox'; import Input from '@/dls/Forms/Input'; +import { ContentSide } from '@/dls/Popover'; +import HoverablePopover from '@/dls/Popover/HoverablePopover'; import SpinnerContainer from '@/dls/Spinner/SpinnerContainer'; +import { TooltipType } from '@/dls/Tooltip'; import usePersistPreferenceGroup from '@/hooks/auth/usePersistPreferenceGroup'; import useRemoveQueryParam from '@/hooks/useRemoveQueryParam'; +import IconInfo from '@/icons/information-circle-outline.svg'; import IconSearch from '@/icons/search.svg'; import { selectTranslations, @@ -110,12 +114,23 @@ const TranslationSelectionBody = () => { label={translation.translatedName.name} onChange={onTranslationsChange(translation.id)} /> + {translation.shortDescription?.description && ( + + + + + + )}
))}
); }, - [onTranslationsChange, selectedTranslations], + [onTranslationsChange, selectedTranslations, t], ); return ( diff --git a/src/components/Navbar/SettingsDrawer/WordByWordSection.module.scss b/src/components/Navbar/SettingsDrawer/WordByWordSection.module.scss index 310deab9c4..b9510c9369 100644 --- a/src/components/Navbar/SettingsDrawer/WordByWordSection.module.scss +++ b/src/components/Navbar/SettingsDrawer/WordByWordSection.module.scss @@ -1,19 +1,9 @@ .checkboxContainer { - & > div { - margin-inline-start: var(--spacing-xxsmall); - margin-block: var(--spacing-xsmall); - } + display: flex; + flex-wrap: wrap; + gap: var(--spacing-small-px); } .separator { margin-block: var(--spacing-small); -} - -.source { - font-weight: var(--font-weight-bold); -} - -.label { - margin-block-start: var(--spacing-xsmall); - margin-block-end: var(--spacing-xxsmall); -} +} \ No newline at end of file diff --git a/src/components/Navbar/SettingsDrawer/WordByWordSection.tsx b/src/components/Navbar/SettingsDrawer/WordByWordSection.tsx index 65f3d3cc75..29e07d95b9 100644 --- a/src/components/Navbar/SettingsDrawer/WordByWordSection.tsx +++ b/src/components/Navbar/SettingsDrawer/WordByWordSection.tsx @@ -8,12 +8,12 @@ import Trans from 'next-translate/Trans'; import useTranslation from 'next-translate/useTranslation'; import { shallowEqual, useSelector } from 'react-redux'; +import CheckboxChip from './CheckboxChip'; import Section from './Section'; import styles from './WordByWordSection.module.scss'; import DataFetcher from '@/components/DataFetcher'; import Counter from '@/dls/Counter/Counter'; -import Checkbox from '@/dls/Forms/Checkbox/Checkbox'; import Select, { SelectSize } from '@/dls/Forms/Select'; import Link, { LinkVariant } from '@/dls/Link/Link'; import Separator from '@/dls/Separator/Separator'; @@ -179,7 +179,7 @@ const WordByWordSection = () => {
- {
- {
- {
- { disabled={shouldDisableWordByWordDisplay} onChange={(isChecked) => onDisplaySettingChange(true, isChecked)} /> - X - + ); }; diff --git a/src/components/Notes/NoteModal/EditNoteMode/EditNoteListItem/NoteListItem.module.scss b/src/components/Notes/NoteModal/EditNoteMode/EditNoteListItem/NoteListItem.module.scss index 22d90a3dc5..f327021063 100644 --- a/src/components/Notes/NoteModal/EditNoteMode/EditNoteListItem/NoteListItem.module.scss +++ b/src/components/Notes/NoteModal/EditNoteMode/EditNoteListItem/NoteListItem.module.scss @@ -1,33 +1,33 @@ .container { - margin-block: var(--spacing-xsmall); - border: 1px var(--color-borders-hairline) solid; - padding: var(--spacing-small); - border-radius: var(--border-radius-rounded); - box-shadow: var(--shadow-small); + margin-block: var(--spacing-xsmall); + border: 1px var(--color-borders-hairline) solid; + padding: var(--spacing-small); + border-radius: var(--border-radius-rounded); + box-shadow: var(--shadow-small); } .headerContainer { - display: flex; - justify-content: space-between; - align-items: center; + display: flex; + justify-content: space-between; + align-items: center; } .noteBody { - margin-block-end: var(--spacing-small); - text-wrap: wrap; - overflow-wrap: break-word; - text-align: justify; - white-space: pre-wrap; + margin-block-end: var(--spacing-small); + text-wrap: wrap; + overflow-wrap: break-word; + text-align: justify; + white-space: pre-wrap; } .shareButtonContainer { - display: flex; - align-items: center; - justify-content: flex-end; + display: flex; + align-items: center; + justify-content: flex-end; } .buttonsContainer { - display: flex; - align-items: center; - justify-content: flex-end; + display: flex; + align-items: center; + justify-content: flex-end; } diff --git a/src/components/Notes/NoteModal/index.tsx b/src/components/Notes/NoteModal/index.tsx index db09492535..4803b4c470 100644 --- a/src/components/Notes/NoteModal/index.tsx +++ b/src/components/Notes/NoteModal/index.tsx @@ -13,6 +13,7 @@ import ContentModal, { ContentModalSize } from '@/dls/ContentModal/ContentModal' import ContentModalHandles from '@/dls/ContentModal/types/ContentModalHandles'; import { BaseResponse } from '@/types/ApiResponses'; import { Note } from '@/types/auth/Note'; +import ZIndexVariant from '@/types/enums/ZIndexVariant'; import { getNotesByVerse, getNoteById } from '@/utils/auth/api'; import { makeGetNoteByIdUrl, makeGetNotesByVerseUrl } from '@/utils/auth/apiPaths'; @@ -23,6 +24,8 @@ interface NoteModalProps { noteId?: string; onNoteUpdated?: (data: Note) => void; onNoteDeleted?: () => void; + isBottomSheetOnMobile?: boolean; + zIndexVariant?: ZIndexVariant; } const NoteModal: React.FC = ({ @@ -32,6 +35,8 @@ const NoteModal: React.FC = ({ noteId, onNoteUpdated, onNoteDeleted, + isBottomSheetOnMobile = false, + zIndexVariant, }) => { const contentModalRef = useRef(); @@ -63,6 +68,8 @@ const NoteModal: React.FC = ({ onClose={onClose} onEscapeKeyDown={onClose} size={ContentModalSize.MEDIUM} + isBottomSheetOnMobile={isBottomSheetOnMobile} + zIndexVariant={zIndexVariant} > = ({ children, isLessonView = false }) => { +const PageContainer: FC = ({ + children, + isLessonView = false, + isSheetsLike = false, + wrapperClassName, + className, +}) => { return (
- {children} +
+ {children} +
); }; diff --git a/src/components/Profile/ChangePasswordForm.tsx b/src/components/Profile/ChangePasswordForm.tsx new file mode 100644 index 0000000000..3b70378378 --- /dev/null +++ b/src/components/Profile/ChangePasswordForm.tsx @@ -0,0 +1,92 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '../Login/SignUpFormFields/consts'; + +import getChangePasswordFormFields from './changePasswordFormFields'; +import Section from './Section'; +import styles from './SharedProfileStyles.module.scss'; + +import FormBuilder from '@/components/FormBuilder/FormBuilder'; +import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import useUpdatePassword from '@/hooks/auth/useUpdatePassword'; +import useTransformFormErrors from '@/hooks/useTransformFormErrors'; +import { TestId } from '@/tests/test-ids'; +import { logButtonClick } from '@/utils/eventLogger'; + +type FormData = { + currentPassword: string; + newPassword: string; + confirmPassword: string; +}; + +interface RenderActionProps { + disabled?: boolean; + onClick?: () => void; +} + +const ChangePasswordForm: FC = () => { + const { t } = useTranslation('profile'); + const { updatePassword, isUpdating } = useUpdatePassword(); + const { transformErrors } = useTransformFormErrors({ + currentPassword: { + fieldNameKey: 'common:form.current-password', + }, + newPassword: { + fieldNameKey: 'common:form.new-password', + extraParams: { + max: PASSWORD_MAX_LENGTH, + min: PASSWORD_MIN_LENGTH, + }, + }, + confirmPassword: { + fieldNameKey: 'common:form.confirm-new-password', + }, + }); + + const formFields = useMemo(() => getChangePasswordFormFields(t), [t]); + + const onFormSubmit = async (data: FormData) => { + logButtonClick('profile_update_password'); + const result = await updatePassword({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + confirmPassword: data.confirmPassword, + }); + + return transformErrors(result); + }; + + const renderAction = (props: RenderActionProps) => ( +
+ +
+ ); + + return ( +
+ +
+ ); +}; + +export default ChangePasswordForm; diff --git a/src/components/Profile/DeleteAccountButton.module.scss b/src/components/Profile/DeleteAccountButton.module.scss index 0dd40f6724..97439adadd 100644 --- a/src/components/Profile/DeleteAccountButton.module.scss +++ b/src/components/Profile/DeleteAccountButton.module.scss @@ -12,3 +12,7 @@ .instructionText { padding-block: var(--spacing-medium); } + +.deleteAccountWarningTitle { + font-weight: var(--font-weight-bold); +} diff --git a/src/components/Profile/DeleteAccountButton.tsx b/src/components/Profile/DeleteAccountButton.tsx index 474f798835..beee7ec169 100644 --- a/src/components/Profile/DeleteAccountButton.tsx +++ b/src/components/Profile/DeleteAccountButton.tsx @@ -4,9 +4,10 @@ import { useRouter } from 'next/router'; import Trans from 'next-translate/Trans'; import useTranslation from 'next-translate/useTranslation'; -import Button, { ButtonType, ButtonVariant } from '../dls/Button/Button'; +import Button, { ButtonSize, ButtonType, ButtonVariant } from '../dls/Button/Button'; import styles from './DeleteAccountButton.module.scss'; +import sharedStyles from './SharedProfileStyles.module.scss'; import Input from '@/dls/Forms/Input'; import Modal from '@/dls/Modal/Modal'; @@ -48,8 +49,10 @@ const DeleteAccountButton = ({ isDisabled }: DeleteAccountButtonProps) => { return ( <> + + + + {t('delete-profile-picture.title')} + {t('delete-profile-picture.subtitle')} + + +
+ + +
+
+
+
+ + ); +}; + +export default DeleteProfilePictureButton; diff --git a/src/components/Profile/EditDetailsForm.tsx b/src/components/Profile/EditDetailsForm.tsx new file mode 100644 index 0000000000..d5e8754980 --- /dev/null +++ b/src/components/Profile/EditDetailsForm.tsx @@ -0,0 +1,90 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import { NAME_MAX_LENGTH, NAME_MIN_LENGTH } from '../Login/SignUpFormFields/consts'; + +import getEditDetailsFormFields from './editDetailsFormFields'; +import Section from './Section'; +import styles from './SharedProfileStyles.module.scss'; + +import FormBuilder from '@/components/FormBuilder/FormBuilder'; +import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import useAuthData from '@/hooks/auth/useAuthData'; +import useUpdateUserProfile from '@/hooks/auth/useUpdateUserProfile'; +import useTransformFormErrors from '@/hooks/useTransformFormErrors'; +import { TestId } from '@/tests/test-ids'; +import { logButtonClick } from '@/utils/eventLogger'; + +type FormData = { + firstName: string; + lastName: string; +}; + +interface RenderActionProps { + disabled?: boolean; + onClick?: () => void; +} + +const extraParams = { + max: NAME_MAX_LENGTH, + min: NAME_MIN_LENGTH, +}; + +const EditDetailsForm: FC = () => { + const { t } = useTranslation('profile'); + const { userData } = useAuthData(); + const { updateProfile, isUpdating } = useUpdateUserProfile(); + const { transformErrors } = useTransformFormErrors({ + firstName: { + fieldNameKey: 'common:form.firstName', + extraParams, + }, + lastName: { + fieldNameKey: 'common:form.lastName', + extraParams, + }, + }); + + const formFields = useMemo(() => getEditDetailsFormFields(t, userData), [t, userData]); + + const onFormSubmit = async (data: FormData) => { + logButtonClick('profile_save_changes'); + const result = await updateProfile({ + firstName: data.firstName, + lastName: data.lastName, + }); + + return transformErrors(result); + }; + + const renderAction = (props: RenderActionProps) => ( +
+ +
+ ); + + return ( +
+ +
+ ); +}; + +export default EditDetailsForm; diff --git a/src/components/Profile/EmailNotificationSettingsForm.module.scss b/src/components/Profile/EmailNotificationSettingsForm.module.scss new file mode 100644 index 0000000000..cbdd3134bd --- /dev/null +++ b/src/components/Profile/EmailNotificationSettingsForm.module.scss @@ -0,0 +1,30 @@ +.title { + font-weight: var(--font-weight-bold); +} + +.checkboxContainer { + align-items: start; + .checkbox { + padding: 0; + margin: 0; + outline: var(--spacing-hairline-px) solid var(--color-daily-progress); + border: none; + height: var(--spacing-medium2-px); + width: var(--spacing-medium2-px); + border-radius: var(--border-radius-xsmall2-px); + + &[data-state='checked'] { + outline-offset: var(--spacing-hairline-px); + outline: var(--spacing-hairline-px) solid var(--color-blue-buttons-and-icons); + .indicator { + background-color: var(--color-blue-buttons-and-icons); + } + } + .indicator { + background-color: var(--color-daily-progress); + border-radius: var(--border-radius-xsmall2-px); + height: var(--spacing-medium2-px); + width: var(--spacing-medium2-px); + } + } +} diff --git a/src/components/Profile/EmailNotificationSettingsForm.tsx b/src/components/Profile/EmailNotificationSettingsForm.tsx new file mode 100644 index 0000000000..1663c09dd3 --- /dev/null +++ b/src/components/Profile/EmailNotificationSettingsForm.tsx @@ -0,0 +1,168 @@ +/* eslint-disable no-underscore-dangle */ +// Novu's IUserPreferenceSettings uses _id convention which violates our naming rules +import { FC, useEffect, useMemo, useState } from 'react'; + +import { ChannelTypeEnum, IUserPreferenceSettings } from '@novu/headless'; +import { groupBy } from 'lodash'; +import useTranslation from 'next-translate/useTranslation'; + +import useUpdateEmailNotificationPreferences from '../../hooks/auth/useUpdateEmailNotificationPreferences'; +import useFetchUserPreferences from '../Notifications/hooks/useFetchUserPreferences'; +import { HeadlessServiceStatus } from '../Notifications/hooks/useHeadlessService'; + +import EmailNotificationSettingsSkeleton from './EmailNotificationSettingsSkeleton'; +import NotificationCheckbox from './NotificationCheckbox'; +import Section from './Section'; +import sharedStyles from './SharedProfileStyles.module.scss'; + +import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import Error from '@/pages/_error'; +import { TestId } from '@/tests/test-ids'; + +const MARKETING_TAG_NAME = 'marketing'; + +const EmailNotificationSettingsForm: FC = () => { + const { t } = useTranslation('profile'); + const { + mutate, + isMutating: isFetchingUserPreferences, + error, + userPreferences, + status, + } = useFetchUserPreferences(); + const [preferences, setPreferences] = useState( + userPreferences as IUserPreferenceSettings[], + ); + const [localPreferences, setLocalPreferences] = useState( + userPreferences as IUserPreferenceSettings[], + ); + const [isSaving, setIsSaving] = useState(false); + const { updatePreference } = useUpdateEmailNotificationPreferences(); + + const groupByTags = useMemo( + () => + groupBy( + localPreferences?.filter( + (preference) => + preference.template.critical === false && + !!preference.template.tags.length && + !preference.template.tags.includes(MARKETING_TAG_NAME), + ), + (preference) => preference.template.tags, + ), + [localPreferences], + ); + + useEffect(() => { + setPreferences(userPreferences as IUserPreferenceSettings[]); + setLocalPreferences(userPreferences as IUserPreferenceSettings[]); + }, [userPreferences]); + + useEffect(() => { + mutate(false); + }, [mutate]); + + const handleToggle = (preference: IUserPreferenceSettings, isChecked: boolean): void => { + const templateId = preference.template._id; + setLocalPreferences((prevPreferences) => + prevPreferences.map((pref) => + pref.template._id === templateId + ? { + ...pref, + preference: { + ...pref.preference, + channels: { ...pref.preference.channels, [ChannelTypeEnum.EMAIL]: isChecked }, + }, + } + : pref, + ), + ); + }; + + const hasChanges = useMemo( + () => + preferences?.some((pref) => { + const localPref = localPreferences.find((lp) => lp.template._id === pref.template._id); + if (!localPref) return false; + const originalEmail = pref.preference.channels[ChannelTypeEnum.EMAIL] ?? false; + const localEmail = localPref.preference.channels[ChannelTypeEnum.EMAIL] ?? false; + return originalEmail !== localEmail; + }) ?? false, + [preferences, localPreferences], + ); + + const handleSave = async (): Promise => { + if (!hasChanges) return; + + setIsSaving(true); + const changedPreferences = localPreferences.filter((localPref) => { + const originalPref = preferences.find((pref) => pref.template._id === localPref.template._id); + if (!originalPref) return false; + const originalEmail = originalPref.preference.channels[ChannelTypeEnum.EMAIL] ?? false; + const localEmail = localPref.preference.channels[ChannelTypeEnum.EMAIL] ?? false; + return originalEmail !== localEmail; + }); + + try { + const updatePromises = changedPreferences.map(async (preference) => { + const isChecked = preference.preference.channels[ChannelTypeEnum.EMAIL] ?? false; + return updatePreference(preference, isChecked); + }); + + await Promise.all(updatePromises); + setPreferences(localPreferences); + } finally { + setIsSaving(false); + } + }; + + const isLoading = status === HeadlessServiceStatus.INITIALIZING || isFetchingUserPreferences; + const hasError = status === HeadlessServiceStatus.ERROR || error; + + if (isLoading) { + return ; + } + + if (hasError) { + return ( +
+ +
+ ); + } + + if (!preferences || preferences.length === 0) return null; + + const flattenedPreferences = Object.values(groupByTags).flat(); + + return ( +
+ {flattenedPreferences.map((preference) => ( + + ))} +
+ +
+
+ ); +}; + +export default EmailNotificationSettingsForm; diff --git a/src/components/Profile/EmailNotificationSettingsSkeleton.module.scss b/src/components/Profile/EmailNotificationSettingsSkeleton.module.scss new file mode 100644 index 0000000000..c1f24b3a31 --- /dev/null +++ b/src/components/Profile/EmailNotificationSettingsSkeleton.module.scss @@ -0,0 +1,47 @@ +.skeletonContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-medium-px); +} + +.notificationRow { + display: flex; + align-items: start; + gap: var(--spacing-xsmall-px); +} + +.checkbox { + width: var(--spacing-medium2-px); + height: var(--spacing-medium2-px); + border-radius: var(--border-radius-xsmall2-px); + flex-shrink: 0; +} + +.textContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-micro-px); + flex: 1; +} + +.title { + height: var(--spacing-small-px); + width: 40%; + border-radius: var(--border-radius-xsmall-px); +} + +.description { + height: var(--font-size-xsmall-px); + width: 80%; + border-radius: var(--border-radius-xsmall-px); +} + +.buttonContainer { + margin-block-start: var(--spacing-small-px); +} + +.button { + height: var(--spacing-medium3-px); + width: calc(var(--spacing-mega-px) + var(--spacing-medium2-px)); + border-radius: var(--border-radius-small-px); +} diff --git a/src/components/Profile/EmailNotificationSettingsSkeleton.tsx b/src/components/Profile/EmailNotificationSettingsSkeleton.tsx new file mode 100644 index 0000000000..2294e065a6 --- /dev/null +++ b/src/components/Profile/EmailNotificationSettingsSkeleton.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; + +import styles from './EmailNotificationSettingsSkeleton.module.scss'; +import Section from './Section'; + +import Skeleton from '@/dls/Skeleton/Skeleton'; + +const EmailNotificationSettingsSkeleton: FC = () => { + return ( +
+
+ {[1, 2].map((index) => ( +
+ +
+ + +
+
+ ))} +
+ +
+
+
+ ); +}; + +export default EmailNotificationSettingsSkeleton; diff --git a/src/components/Profile/NotificationCheckbox.tsx b/src/components/Profile/NotificationCheckbox.tsx new file mode 100644 index 0000000000..496216c4f3 --- /dev/null +++ b/src/components/Profile/NotificationCheckbox.tsx @@ -0,0 +1,50 @@ +/* eslint-disable no-underscore-dangle */ +import { FC } from 'react'; + +import { ChannelTypeEnum, IUserPreferenceSettings } from '@novu/headless'; +import { Translate } from 'next-translate'; + +import styles from './EmailNotificationSettingsForm.module.scss'; + +import Checkbox from '@/dls/Forms/Checkbox/Checkbox'; +import { TestId } from '@/tests/test-ids'; + +interface NotificationCheckboxProps { + preference: IUserPreferenceSettings; + onToggle: (preference: IUserPreferenceSettings, isChecked: boolean) => void; + disabled: boolean; + t: Translate; +} + +const NotificationCheckbox: FC = ({ + preference, + onToggle, + disabled, + t, +}) => { + const { template } = preference; + const isEmailEnabled = preference.preference.channels[ChannelTypeEnum.EMAIL] ?? false; + const title = t(`notifications.${template.name}.title`); + const description = t(`notifications.${template.name}.description`); + + return ( + + {title}: {description} + + } + checked={isEmailEnabled} + onChange={(isChecked) => onToggle(preference, isChecked)} + disabled={disabled} + /> + ); +}; + +export default NotificationCheckbox; diff --git a/src/components/Profile/PersonalizationForm.module.scss b/src/components/Profile/PersonalizationForm.module.scss new file mode 100644 index 0000000000..09708a622e --- /dev/null +++ b/src/components/Profile/PersonalizationForm.module.scss @@ -0,0 +1,54 @@ +@use 'src/styles/breakpoints'; + +.profilePictureContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-small-px); + .profilePictureTitle { + color: var(--color-text-default-new); + font-size: var(--font-size-small-px); + } + .profilePictureDetailAction { + display: flex; + flex-direction: column; + gap: var(--spacing-small-px); + @include breakpoints.tablet { + flex-direction: row; + justify-content: space-between; + } + .profilePictureDetail { + display: flex; + align-items: center; + gap: var(--spacing-xxsmall-px); + .profilePictureImage { + svg { + width: calc(var(--spacing-large-px) * 2); + height: calc(var(--spacing-large-px) * 2); + } + .profilePictureImageElement { + width: calc(var(--spacing-large-px) * 2); + height: calc(var(--spacing-large-px) * 2); + border-radius: 50%; + object-fit: cover; + } + } + .profilePictureDescription { + p { + color: var(--color-text-faded-new); + font-size: var(--font-size-xxsmall-px); + } + } + } + .profilePictureAction { + display: flex; + align-items: center; + gap: var(--spacing-small-px); + .profilePictureInput { + display: none; + } + .uploadPictureButton { + background-color: var(--color-background-alternative-faint); + } + } + } +} diff --git a/src/components/Profile/PersonalizationForm.tsx b/src/components/Profile/PersonalizationForm.tsx new file mode 100644 index 0000000000..fd3f40e0af --- /dev/null +++ b/src/components/Profile/PersonalizationForm.tsx @@ -0,0 +1,103 @@ +import type { FC } from 'react'; + +import classNames from 'classnames'; +import Image from 'next/image'; +import useTranslation from 'next-translate/useTranslation'; + +import DeleteProfilePictureButton from './DeleteProfilePictureButton'; +import styles from './PersonalizationForm.module.scss'; +import Section from './Section'; + +import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import useAuthData from '@/hooks/auth/useAuthData'; +import useProfilePictureForm from '@/hooks/auth/useProfilePictureForm'; +import DefaultUserIcon from '@/icons/default-user.svg'; +import { TestId } from '@/tests/test-ids'; +import { logButtonClick } from '@/utils/eventLogger'; + +const PersonalizationForm: FC = () => { + const { t } = useTranslation('profile'); + const { userData } = useAuthData(); + const hasProfilePicture = !!userData?.avatars?.large; + + const { + fileInputRef, + handleUploadPicture, + handleFileSelect, + isProcessing, + translationParams, + handleRemovePicture, + isRemoving, + } = useProfilePictureForm(); + + const onUploadPicture = () => { + logButtonClick('profile_upload_picture'); + handleUploadPicture(); + }; + + const onRemovePicture = () => { + logButtonClick('profile_remove_picture'); + handleRemovePicture(); + }; + + return ( +
+
+

{t('profile-picture')}

+
+
+
+ {hasProfilePicture ? ( + {t('profile-picture')} + ) : ( + + )} +
+
+

{t('max-file-size', { size: translationParams.maxSize })}

+

{t('allowed-formats', { formats: translationParams.allowedFormats })}

+
+
+
+ + + {hasProfilePicture && ( + + )} +
+
+
+
+ ); +}; + +export default PersonalizationForm; diff --git a/src/components/Profile/Section.module.scss b/src/components/Profile/Section.module.scss new file mode 100644 index 0000000000..3e4ea40524 --- /dev/null +++ b/src/components/Profile/Section.module.scss @@ -0,0 +1,11 @@ +@use 'src/styles/breakpoints'; + +.section { + display: flex; + flex-direction: column; + gap: var(--spacing-medium2-px); + .title { + font-size: var(--font-size-medium-px); + font-weight: var(--font-weight-bold); + } +} diff --git a/src/components/Profile/Section.tsx b/src/components/Profile/Section.tsx new file mode 100644 index 0000000000..55e087f8d6 --- /dev/null +++ b/src/components/Profile/Section.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import styles from './Section.module.scss'; + +interface SectionProps { + title: string; + children: React.ReactNode; + dataTestId?: string; +} + +const Section: React.FC = ({ title, children, dataTestId }) => { + return ( +
+

{title}

+ {children} +
+ ); +}; + +export default Section; diff --git a/src/components/Profile/SharedProfileStyles.module.scss b/src/components/Profile/SharedProfileStyles.module.scss new file mode 100644 index 0000000000..dee036c7de --- /dev/null +++ b/src/components/Profile/SharedProfileStyles.module.scss @@ -0,0 +1,63 @@ +@use 'src/styles/breakpoints'; + +@mixin input-base-styles { + background-color: transparent; + transition: border-color var(--transition-moderate) ease; + padding: var(--spacing-small-px); + border-radius: var(--border-radius-xsmall-px); + border: var(--spacing-hairline-px) solid var(--color-separators-ayah-level); + + &:has(input:disabled) { + background-color: var(--color-background-alternative-faint); + } + + &:hover:not(:has(input:disabled)) { + border-color: var(--color-success-medium); + } + + input { + height: auto; + } +} + +.button { + padding-block: var(--spacing-xxxsmall-px); + padding-inline: var(--spacing-small-px); + border-radius: var(--border-radius-small-px); + font-size: var(--font-size-xsmall-px); + font-weight: var(--font-weight-semibold); +} +.formContainer { + display: grid !important; // to override the default display: flex + gap: var(--spacing-medium-px); + @include breakpoints.tablet { + grid-template-columns: repeat(2, 1fr); + } + .formInputContainer { + width: 100%; + .formInput { + @include input-base-styles; + width: unset; + } + } +} + +.passwordFormContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-medium-px); + @include breakpoints.tablet { + max-width: calc(50% - calc(var(--spacing-medium-px) / 2)); + } + .passwordFormInput { + @include input-base-styles; + height: calc(2 * var(--spacing-medium2-px)); + } +} + +.loadingContainer { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-mega-px); +} diff --git a/src/components/Profile/changePasswordFormFields.tsx b/src/components/Profile/changePasswordFormFields.tsx new file mode 100644 index 0000000000..7661aa367d --- /dev/null +++ b/src/components/Profile/changePasswordFormFields.tsx @@ -0,0 +1,88 @@ +import React from 'react'; + +import { Translate } from 'next-translate'; + +import PasswordField from '../Login/SignUpForm/PasswordField'; +import PasswordInput from '../Login/SignUpForm/PasswordInput'; + +import styles from './SharedProfileStyles.module.scss'; + +import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes'; +import { TestId } from '@/tests/test-ids'; +import { RuleType } from '@/types/FieldRule'; +import { FormFieldType } from '@/types/FormField'; + +/** + * Get form fields for the change password form + * @param {Translate} t - Translation function + * @returns {FormBuilderFormField[]} Array of form fields + */ +const getChangePasswordFormFields = (t: Translate): FormBuilderFormField[] => [ + { + field: 'currentPassword', + type: FormFieldType.Password, + label: t('common:form.current-password'), + placeholder: t('login:current-password-placeholder'), + containerClassName: styles.formInputContainer, + rules: [ + { + type: RuleType.Required, + value: true, + errorMessage: t('common:errors.required', { fieldName: t('common:form.current-password') }), + }, + ], + customRender: ({ value, onChange, placeholder }) => ( + + ), + }, + { + field: 'newPassword', + type: FormFieldType.Password, + label: t('common:form.new-password'), + placeholder: t('login:new-password-placeholder'), + containerClassName: styles.formInputContainer, + customRender: ({ value, onChange, placeholder }) => ( + + ), + }, + { + field: 'confirmPassword', + type: FormFieldType.Password, + label: t('common:form.confirm-new-password'), + placeholder: t('login:confirm-new-password-placeholder'), + containerClassName: styles.formInputContainer, + rules: [ + { + type: RuleType.Required, + value: true, + errorMessage: t('common:errors.required', { fieldName: t('common:form.confirm-password') }), + }, + ], + customRender: ({ value, onChange, placeholder }) => ( + + ), + }, +]; + +export default getChangePasswordFormFields; diff --git a/src/components/Profile/editDetailsFormFields.tsx b/src/components/Profile/editDetailsFormFields.tsx new file mode 100644 index 0000000000..2d15712b50 --- /dev/null +++ b/src/components/Profile/editDetailsFormFields.tsx @@ -0,0 +1,153 @@ +import React from 'react'; + +import { Translate } from 'next-translate'; + +import { NAME_MAX_LENGTH, NAME_MIN_LENGTH } from '../Login/SignUpFormFields/consts'; + +import styles from './SharedProfileStyles.module.scss'; + +import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes'; +import { REGEX_NAME } from '@/components/Login/SignUpFormFields/nameFields'; +import Input from '@/dls/Forms/Input'; +import UserProfile from '@/types/auth/UserProfile'; +import { RuleType } from '@/types/FieldRule'; +import { FormFieldType } from '@/types/FormField'; + +/** + * Get form fields for the edit details form + * @param {Translate} t - Translation function + * @param {UserProfile | undefined} userData - User profile data + * @returns {FormBuilderFormField[]} Array of form fields + */ +const getEditDetailsFormFields = (t: Translate, userData?: UserProfile): FormBuilderFormField[] => [ + { + field: 'email', + type: FormFieldType.Text, + label: t('common:form.email'), + placeholder: t('login:email-placeholder'), + defaultValue: userData?.email || '', + containerClassName: styles.formInputContainer, + customRender: ({ value, placeholder }) => ( + + ), + }, + { + field: 'username', + type: FormFieldType.Text, + label: t('common:form.username'), + placeholder: t('login:username-placeholder'), + defaultValue: userData?.username || '', + containerClassName: styles.formInputContainer, + customRender: ({ value, placeholder }) => ( + + ), + }, + { + field: 'firstName', + type: FormFieldType.Text, + label: t('common:form.firstName'), + placeholder: t('login:first-name-placeholder'), + defaultValue: userData?.firstName || '', + containerClassName: styles.formInputContainer, + rules: [ + { + type: RuleType.Required, + value: true, + errorMessage: t('common:errors.required', { fieldName: t('common:form.firstName') }), + }, + { + type: RuleType.MinimumLength, + value: NAME_MIN_LENGTH, + errorMessage: t('common:errors.min', { + fieldName: t('common:form.firstName'), + min: NAME_MIN_LENGTH, + }), + }, + { + type: RuleType.MaximumLength, + value: NAME_MAX_LENGTH, + errorMessage: t('common:errors.max', { + fieldName: t('common:form.firstName'), + max: NAME_MAX_LENGTH, + }), + }, + { + type: RuleType.Regex, + value: REGEX_NAME, + errorMessage: t('common:errors.invalid', { fieldName: t('common:form.firstName') }), + }, + ], + customRender: ({ value, onChange, placeholder }) => ( + + ), + }, + { + field: 'lastName', + type: FormFieldType.Text, + label: t('common:form.lastName'), + placeholder: t('login:last-name-placeholder'), + defaultValue: userData?.lastName || '', + containerClassName: styles.formInputContainer, + rules: [ + { + type: RuleType.Required, + value: true, + errorMessage: t('common:errors.required', { fieldName: t('common:form.lastName') }), + }, + { + type: RuleType.MinimumLength, + value: NAME_MIN_LENGTH, + errorMessage: t('common:errors.min', { + fieldName: t('common:form.lastName'), + min: NAME_MIN_LENGTH, + }), + }, + { + type: RuleType.MaximumLength, + value: NAME_MAX_LENGTH, + errorMessage: t('common:errors.max', { + fieldName: t('common:form.lastName'), + max: NAME_MAX_LENGTH, + }), + }, + { + type: RuleType.Regex, + value: REGEX_NAME, + errorMessage: t('common:errors.invalid', { fieldName: t('common:form.lastName') }), + }, + ], + customRender: ({ value, onChange, placeholder }) => ( + + ), + }, +]; + +export default getEditDetailsFormFields; diff --git a/src/components/QuranReader/ContextMenu.module.scss b/src/components/QuranReader/ContextMenu.module.scss index 65015be2ed..0c7ed5205a 100644 --- a/src/components/QuranReader/ContextMenu.module.scss +++ b/src/components/QuranReader/ContextMenu.module.scss @@ -1,5 +1,5 @@ -@use "src/styles/constants"; -@use "src/styles/breakpoints"; +@use 'src/styles/constants'; +@use 'src/styles/breakpoints'; $sections-container-height: 2.25rem; $pixel: 1px; @@ -13,18 +13,18 @@ $pixel: 1px; position: fixed; z-index: var(--z-index-sticky); transition: transform var(--transition-regular); - width: 100%; + inline-size: 100%; inset-block-start: calc($pixel * -1); - padding-bottom: $pixel; - min-height: var(--spacing-medium); + padding-block-end: $pixel; + min-block-size: var(--spacing-medium); will-change: transform; &:after { display: block; - content: ""; - width: var(--progress); + content: ''; + inline-size: var(--progress); transition: var(--transition-fast); transition-timing-function: linear; - height: var(--spacing-micro); + block-size: var(--spacing-micro); background: var(--color-success-deep); inset-block-end: calc(-1 * var(--spacing-micro)); } @@ -32,13 +32,16 @@ $pixel: 1px; .hide { display: none; - height: 0; + block-size: 0; visibility: hidden; } .visibleContainer { transform: translate3d(0, var(--navbar-container-height), 0); // https://ptgamr.github.io/2014-09-13-translate3d-vs-translate-performance/ + @include breakpoints.smallerThanTablet { + transform: translate3d(0, calc(var(--navbar-container-height) + var(--banner-height)), 0); + } } .withVisibleBanner { @@ -46,13 +49,13 @@ $pixel: 1px; } .expandedContainer { - min-height: var(--spacing-mega); + min-block-size: var(--spacing-mega); } .withVisibleSideBar { @include breakpoints.tablet { margin-inline-end: constants.$notes-side-bar-desktop-width; - width: calc(100% - #{constants.$notes-side-bar-desktop-width}); + inline-size: calc(100% - #{constants.$notes-side-bar-desktop-width}); } } @@ -66,8 +69,8 @@ $pixel: 1px; } .sectionsContainer { - height: $sections-container-height; - width: 100%; + block-size: $sections-container-height; + inline-size: 100%; padding-block-start: var(--spacing-micro); padding-block-end: var(--spacing-micro); display: flex; @@ -79,18 +82,19 @@ $pixel: 1px; background: var(--color-background-default); background: var(--color-background-elevated); @include breakpoints.tablet { + padding-block-start: var(--spacing-micro); padding-inline: calc(1.2 * var(--spacing-mega)); } } .halfSection { - width: 50%; // when reading preference toggle is hidden + inline-size: 50%; // when reading preference toggle is hidden } .section { - width: calc(100% / 3); + inline-size: calc(100% / 3); @include breakpoints.smallerThanTablet { - width: 50%; + inline-size: 50%; } } @@ -106,7 +110,7 @@ $pixel: 1px; display: flex; justify-content: space-between; flex-direction: column; - width: 100%; + inline-size: 100%; } .primaryInfo { diff --git a/src/components/QuranReader/ContextMenu.tsx b/src/components/QuranReader/ContextMenu.tsx deleted file mode 100644 index bfb23f795a..0000000000 --- a/src/components/QuranReader/ContextMenu.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -import React, { useContext, useMemo } from 'react'; - -import classNames from 'classnames'; -import useTranslation from 'next-translate/useTranslation'; -import { shallowEqual, useDispatch, useSelector } from 'react-redux'; - -import styles from './ContextMenu.module.scss'; -import ReadingPreferenceSwitcher, { - ReadingPreferenceSwitcherType, -} from './ReadingPreferenceSwitcher'; -import TajweedColors from './TajweedBar/TajweedBar'; - -import { useOnboarding } from '@/components/Onboarding/OnboardingProvider'; -import { SwitchSize } from '@/dls/Switch/Switch'; -import useGetMushaf from '@/hooks/useGetMushaf'; -import ChevronDownIcon from '@/icons/chevron-down.svg'; -import { selectNavbar } from '@/redux/slices/navbar'; -import { selectContextMenu } from '@/redux/slices/QuranReader/contextMenu'; -import { selectNotes } from '@/redux/slices/QuranReader/notes'; -import { selectLastReadVerseKey } from '@/redux/slices/QuranReader/readingTracker'; -import { - selectIsSidebarNavigationVisible, - setIsSidebarNavigationVisible, -} from '@/redux/slices/QuranReader/sidebarNavigation'; -import { Mushaf } from '@/types/QuranReader'; -import { getChapterData, getChapterReadingProgress } from '@/utils/chapter'; -import { logEvent } from '@/utils/eventLogger'; -import { getJuzNumberByHizb } from '@/utils/juz'; -import { toLocalizedNumber } from '@/utils/locale'; -import { isMobile } from '@/utils/responsive'; -import { getVerseNumberFromKey } from '@/utils/verse'; -import DataContext from 'src/contexts/DataContext'; - -const ContextMenu = () => { - const dispatch = useDispatch(); - const chaptersData = useContext(DataContext); - const isSidebarNavigationVisible = useSelector(selectIsSidebarNavigationVisible); - const { t, lang } = useTranslation('common'); - const mushaf = useGetMushaf(); - const isSideBarVisible = useSelector(selectNotes, shallowEqual).isVisible; - const { isExpanded, showReadingPreferenceSwitcher: isReadingPreferenceSwitcherVisible } = - useSelector(selectContextMenu, shallowEqual); - - const { isActive } = useOnboarding(); - const { isVisible: isNavbarVisible } = useSelector(selectNavbar, shallowEqual); - const showNavbar = isNavbarVisible || isActive; - const showReadingPreferenceSwitcher = isReadingPreferenceSwitcherVisible && !isActive; - - const { verseKey, chapterId, page, hizb } = useSelector(selectLastReadVerseKey, shallowEqual); - const chapterData = useMemo(() => { - return chapterId ? getChapterData(chaptersData, chapterId) : null; - }, [chapterId, chaptersData]); - const juzNumber = useMemo(() => { - return hizb ? toLocalizedNumber(getJuzNumberByHizb(Number(hizb)), lang) : null; - }, [hizb, lang]); - const localizedHizb = useMemo(() => { - return toLocalizedNumber(Number(hizb), lang); - }, [hizb, lang]); - const localizedPageNumber = useMemo(() => { - return toLocalizedNumber(Number(page), lang); - }, [page, lang]); - - // if it's SSR or the first time we render this - if (!verseKey) { - return <>; - } - const verse = getVerseNumberFromKey(verseKey); - const progress = getChapterReadingProgress(verse, chapterData.versesCount); - - return ( -
-
-
-
-

{ - logEvent( - `sidebar_navigation_${isSidebarNavigationVisible ? 'close' : 'open'}_trigger`, - ); - e.stopPropagation(); - if (isSidebarNavigationVisible === 'auto') { - // eslint-disable-next-line no-unneeded-ternary - const shouldBeVisible = isMobile() ? true : false; - dispatch(setIsSidebarNavigationVisible(shouldBeVisible)); - } else { - dispatch(setIsSidebarNavigationVisible(!isSidebarNavigationVisible)); - } - }} - > - {chapterData.transliteratedName} - - - -

-
-
- {showReadingPreferenceSwitcher && ( -
- -
- )} -
-
-

-

- {isExpanded && ( - - {/* eslint-disable-next-line i18next/no-literal-string */} - {t('juz')} {juzNumber} / {t('hizb')} {localizedHizb} -{' '} - - )} - - {t('page')} {localizedPageNumber} - -

-
-
-
- {mushaf === Mushaf.QCFTajweedV4 && } -
- ); -}; - -export default ContextMenu; diff --git a/src/components/QuranReader/ContextMenu/README.md b/src/components/QuranReader/ContextMenu/README.md new file mode 100644 index 0000000000..2a406a28da --- /dev/null +++ b/src/components/QuranReader/ContextMenu/README.md @@ -0,0 +1,73 @@ +# ContextMenu Component + +This directory contains the refactored version of the ContextMenu component for the Quran reader application. + +## Structure + +The component has been refactored into a more maintainable and scalable structure: + +``` +ContextMenu/ +├── components/ # Individual UI components +│ ├── ChapterNavigation.tsx # Chapter name and sidebar toggle +│ ├── MobileReadingTabs.tsx # Mobile-specific reading tabs +│ ├── PageBookmarkAction.tsx # Bookmark functionality +│ ├── PageInfo.tsx # Juz, Hizb, and Page information +│ ├── ProgressBar.tsx # Reading progress visualization +│ └── styles/ # Component-specific styles +├── hooks/ # Custom hooks for state management +│ └── useContextMenuState.ts # Centralized state management +├── styles/ # Styles for the component +│ └── ContextMenu.module.scss # Main styles +├── index.tsx # Main component that composes everything +└── README.md # Documentation +``` + +## Improvements + +1. **Separation of Concerns**: + - UI components are separated from state management + - Each component has a single responsibility + - Mobile and desktop experiences are handled separately + +2. **Improved Accessibility**: + - Fixed accessibility issues with proper keyboard navigation + - Added proper ARIA roles for interactive elements + - Enhanced focus management for interactive components + +3. **Maintainability**: + - Smaller, focused components are easier to understand and modify + - State management is centralized in a custom hook + - Responsive behavior is clearly separated + +4. **Scalability**: + - New features can be added by creating new components + - State management is isolated and can be extended easily + - Responsive design patterns are established for future components + +5. **Readability**: + - Clear component naming and organization + - Consistent code style and patterns + - Well-documented component responsibilities + +## Features + +- **Chapter Navigation**: Toggle sidebar and display current chapter +- **Page Information**: Display Juz, Hizb, and Page numbers +- **Reading Preferences**: Switch between different reading modes +- **Progress Tracking**: Visual indicator of reading progress +- **Mobile Optimization**: Specialized components for mobile experience +- **Responsive Layout**: Adapts to different screen sizes and orientations + +## Usage + +The main component can be imported and used the same way as before: + +```tsx +import ContextMenu from '@/components/QuranReader/ContextMenu'; + +// In your component + +``` + +No changes are required to the component's API or usage. diff --git a/src/components/QuranReader/ContextMenu/components/ChapterNavigation.tsx b/src/components/QuranReader/ContextMenu/components/ChapterNavigation.tsx new file mode 100644 index 0000000000..67fd1485b8 --- /dev/null +++ b/src/components/QuranReader/ContextMenu/components/ChapterNavigation.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; + +import styles from '../styles/ContextMenu.module.scss'; + +import ChevronDownIcon from '@/icons/chevron-down.svg'; +import { toLocalizedNumber } from '@/utils/locale'; + +interface ChapterNavigationProps { + chapterName: string; + isSidebarNavigationVisible: boolean | 'auto'; + onToggleSidebar: (e: React.MouseEvent | React.KeyboardEvent) => void; + chapterNumber: number; +} + +/** + * Component for displaying chapter name and handling sidebar navigation toggle + * @returns {JSX.Element} React component that displays chapter name and handles sidebar navigation toggle + */ +const ChapterNavigation: React.FC = ({ + chapterName, + isSidebarNavigationVisible, + onToggleSidebar, + chapterNumber, +}) => { + const { lang } = useTranslation(); + return ( +

+ { + if (e.key === 'Enter' || e.key === ' ') { + onToggleSidebar(e); + } + }} + > + {`${toLocalizedNumber( + chapterNumber, + lang, + )}. ${chapterName}`} + + + + +

+ ); +}; + +export default ChapterNavigation; diff --git a/src/components/QuranReader/ContextMenu/components/MobileReadingTabs.tsx b/src/components/QuranReader/ContextMenu/components/MobileReadingTabs.tsx new file mode 100644 index 0000000000..b020f15541 --- /dev/null +++ b/src/components/QuranReader/ContextMenu/components/MobileReadingTabs.tsx @@ -0,0 +1,189 @@ +import React from 'react'; + +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { useDispatch, useSelector } from 'react-redux'; + +import readingPreferenceStyles from '../../ReadingPreferenceSwitcher/ReadingPreference.module.scss'; +import styles from '../styles/MobileReadingTabs.module.scss'; + +import { Tab } from '@/components/dls/Tabs/Tabs'; +import { getReadingPreferenceIcon } from '@/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceIcon'; +import usePersistPreferenceGroup from '@/hooks/auth/usePersistPreferenceGroup'; +import useScrollRestoration from '@/hooks/useScrollRestoration'; +import { setLockVisibilityState } from '@/redux/slices/navbar'; +import { + selectReadingPreferences, + setReadingPreference, +} from '@/redux/slices/QuranReader/readingPreferences'; +import { selectLastReadVerseKey } from '@/redux/slices/QuranReader/readingTracker'; +import { logValueChange } from '@/utils/eventLogger'; +import { getVerseNumberFromKey } from '@/utils/verse'; +import PreferenceGroup from 'types/auth/PreferenceGroup'; +import { ReadingPreference } from 'types/QuranReader'; + +interface MobileReadingTabsProps { + t: (key: string) => string; +} + +/** + * Mobile-specific tabs for switching between reading preferences + * Appears only on mobile breakpoints when the navbar is visible + * + * @param {object} props - Component props + * @param {Function} props.t - Translation function + * @returns {JSX.Element} React component for mobile reading preference tabs + */ +const MobileReadingTabs: React.FC = ({ t }) => { + // Redux state + const readingPreferences = useSelector(selectReadingPreferences); + const lastReadVerseKey = useSelector(selectLastReadVerseKey); + const { readingPreference } = readingPreferences; + + // Hooks + const router = useRouter(); + const dispatch = useDispatch(); + const { + actions: { onSettingsChange }, + } = usePersistPreferenceGroup(); + + const lastReadVerse = lastReadVerseKey.verseKey + ? getVerseNumberFromKey(lastReadVerseKey.verseKey).toString() + : undefined; + + // Define tabs with icons + const tabs: Tab[] = [ + { + title: t('reading-preference.translation'), + value: ReadingPreference.Translation, + id: 'translation-tab', + }, + { + title: t('reading-preference.reading'), + value: ReadingPreference.Reading, + id: 'reading-tab', + }, + ]; + + /** + * Handle switching between reading preferences + * + * @param {ReadingPreference} view - The new reading preference to switch to + */ + // Use the shared scroll restoration hook + const { restoreScrollPosition } = useScrollRestoration(); + + /** + * Prepares URL parameters for the reading preference change + * + * @returns {object} URL object with query parameters + */ + const prepareUrlParams = () => { + // Prepare URL parameters + const newQueryParams = { ...router.query }; + + // Handle starting verse based on context + if (parseInt(lastReadVerse, 10) > 1) { + // Track the verse if we're not at the beginning + newQueryParams.startingVerse = lastReadVerse; + } + + // Create the new URL object + return { + pathname: router.pathname, + query: newQueryParams, + }; + }; + + /** + * Handle the post-navigation tasks after the URL has been updated + * + * @param {ReadingPreference} view - The new reading preference + * @param {number} scrollPosition - The scroll position to maintain + * @param {boolean} isTranslationTab - Whether this is the translation tab + */ + const handlePostNavigation = ( + view: ReadingPreference, + scrollPosition: number, + isTranslationTab: boolean, + ) => { + // Update reading preference in Redux + onSettingsChange( + 'readingPreference', + view, + setReadingPreference(view), + setReadingPreference(readingPreference), + PreferenceGroup.READING, + ); + + // Use the shared hook to restore scroll position and handle completion + restoreScrollPosition(scrollPosition, isTranslationTab, () => { + dispatch(setLockVisibilityState(false)); + }); + }; + + const onViewSwitched = (view: ReadingPreference) => { + // Log the change event + logValueChange('mobile_tabs_reading_preference', readingPreference, view); + + // Lock navbar visibility state to prevent flickering during tab switching + dispatch(setLockVisibilityState(true)); + + // Save current scroll position + const scrollPosition = window.scrollY; + + // Check if this is the translation tab which tends to cause scrolling + const isTranslationTab = view === ReadingPreference.Translation; + + // Get URL parameters for the navigation + const newUrlObject = prepareUrlParams(); + + // Update the URL and then handle post-navigation tasks + router.replace(newUrlObject, null, { shallow: true, scroll: false }).then(() => { + handlePostNavigation(view, scrollPosition, isTranslationTab); + }); + }; + + // Custom tab rendering to include icons + const renderTabs = () => { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); + }; + + return
{renderTabs()}
; +}; + +export default MobileReadingTabs; diff --git a/src/components/QuranReader/ContextMenu/components/PageBookmarkAction.tsx b/src/components/QuranReader/ContextMenu/components/PageBookmarkAction.tsx new file mode 100644 index 0000000000..abbeb8edeb --- /dev/null +++ b/src/components/QuranReader/ContextMenu/components/PageBookmarkAction.tsx @@ -0,0 +1,65 @@ +import React, { useCallback } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { useSelector, shallowEqual } from 'react-redux'; + +import styles from '../styles/ContextMenu.module.scss'; + +import usePageBookmark from '@/hooks/usePageBookmark'; +import BookmarkedIcon from '@/icons/bookmark.svg'; +import UnBookmarkedIcon from '@/icons/unbookmarked.svg'; +import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; +import { getMushafId } from '@/utils/api'; +import { logButtonClick } from '@/utils/eventLogger'; + +interface PageBookmarkActionProps { + pageNumber: number; +} + +/** + * Component for bookmarking a Quran page + * @returns {JSX.Element} A React component that displays a bookmark icon for the current page + */ +const PageBookmarkAction: React.FC = React.memo(({ pageNumber }) => { + const quranReaderStyles = useSelector(selectQuranReaderStyles, shallowEqual); + const mushafId = getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf; + const { t } = useTranslation(); + + // Use custom hook for all bookmark logic + const { isPageBookmarked, handleToggleBookmark } = usePageBookmark({ + pageNumber, + mushafId, + }); + + // Helper: Get event name for analytics + const getEventName = useCallback(() => { + const action = isPageBookmarked ? 'un_bookmark' : 'bookmark'; + return `context_menu_page_${action}`; + }, [isPageBookmarked]); + + const onToggleBookmarkClicked = useCallback(() => { + logButtonClick(getEventName()); + handleToggleBookmark(); + }, [getEventName, handleToggleBookmark]); + + const bookmarkIcon = isPageBookmarked ? ( + + ) : ( + + ); + + return ( + + ); +}); + +export default PageBookmarkAction; diff --git a/src/components/QuranReader/ContextMenu/components/PageInfo.tsx b/src/components/QuranReader/ContextMenu/components/PageInfo.tsx new file mode 100644 index 0000000000..bf05e2d35a --- /dev/null +++ b/src/components/QuranReader/ContextMenu/components/PageInfo.tsx @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; + +import styles from '../styles/ContextMenu.module.scss'; + +import PageBookmarkAction from './PageBookmarkAction'; + +import { toLocalizedNumber } from '@/utils/locale'; + +interface PageInfoProps { + juzNumber: string; + hizbNumber: string; + pageNumber: string | number; + t: (key: string) => string; + containerClassName?: string; +} + +/** + * Component for displaying Quran page information (juz, hizb, page numbers) + * @returns {JSX.Element} A React component that displays page information including juz, hizb, and page numbers + */ +const PageInfo: React.FC = ({ + juzNumber, + hizbNumber, + pageNumber, + t, + containerClassName, +}) => { + const { lang } = useTranslation(); + + const localizedPageNumber = toLocalizedNumber(Number(pageNumber), lang); + + // Memoize the bookmark component to prevent unnecessary re-renders + const bookmarkComponent = useMemo(() => { + return ; + }, [pageNumber]); + + return ( +
+
+ {bookmarkComponent} + + {t('page')} {localizedPageNumber} + +
+ +

+ {t('juz')} {juzNumber} / {t('hizb')} {hizbNumber} +

+
+ ); +}; + +export default PageInfo; diff --git a/src/components/QuranReader/ContextMenu/components/ProgressBar.tsx b/src/components/QuranReader/ContextMenu/components/ProgressBar.tsx new file mode 100644 index 0000000000..e0c6a7f6b1 --- /dev/null +++ b/src/components/QuranReader/ContextMenu/components/ProgressBar.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import classNames from 'classnames'; + +import styles from '../styles/ProgressBar.module.scss'; + +interface ProgressBarProps { + /** + * The progress value (0-100) + */ + progress: number; + /** + * Optional CSS class name to apply to the container + */ + className?: string; +} + +/** + * ProgressBar component for displaying reading progress + * + * @param {ProgressBarProps} props - Component props + * @returns {JSX.Element} The ProgressBar component + */ +const ProgressBar: React.FC = ({ progress, className }) => { + return ( +
+
+
+
+
+ ); +}; + +export default ProgressBar; diff --git a/src/components/QuranReader/ContextMenu/components/SettingsButton.module.scss b/src/components/QuranReader/ContextMenu/components/SettingsButton.module.scss new file mode 100644 index 0000000000..cce2c45a46 --- /dev/null +++ b/src/components/QuranReader/ContextMenu/components/SettingsButton.module.scss @@ -0,0 +1,4 @@ +.settingsButtonIcon { + color: var(--color-blue-buttons-and-icons); + fill: var(--color-blue-buttons-and-icons); +} diff --git a/src/components/QuranReader/ContextMenu/components/SettingsButton.tsx b/src/components/QuranReader/ContextMenu/components/SettingsButton.tsx new file mode 100644 index 0000000000..e90c457703 --- /dev/null +++ b/src/components/QuranReader/ContextMenu/components/SettingsButton.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { useDispatch } from 'react-redux'; + +import styles from './SettingsButton.module.scss'; + +import Button, { ButtonShape, ButtonVariant } from '@/dls/Button/Button'; +import IconSettings from '@/icons/settings.svg'; +import { setIsSettingsDrawerOpen } from '@/redux/slices/navbar'; +import { logEvent } from '@/utils/eventLogger'; + +interface SettingsButtonProps { + className?: string; + ariaId?: string; + dataTestId?: string; +} + +const SettingsButton: React.FC = ({ + className, + ariaId = 'settings-button', + dataTestId = 'settings-button', +}) => { + const { t } = useTranslation('common'); + const dispatch = useDispatch(); + const openSettings = () => { + logEvent('drawer_settings_open'); + dispatch(setIsSettingsDrawerOpen(true)); + }; + + return ( + + ); +}; + +export default SettingsButton; diff --git a/src/components/QuranReader/ContextMenu/hooks/useContextMenuState.ts b/src/components/QuranReader/ContextMenu/hooks/useContextMenuState.ts new file mode 100644 index 0000000000..a20fcbd3c5 --- /dev/null +++ b/src/components/QuranReader/ContextMenu/hooks/useContextMenuState.ts @@ -0,0 +1,116 @@ +import { useContext, useMemo } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; + +import { useOnboarding } from '@/components/Onboarding/OnboardingProvider'; +import useDebounceNavbarVisibility from '@/hooks/useDebounceNavbarVisibility'; +import useGetMushaf from '@/hooks/useGetMushaf'; +import { selectNavbar } from '@/redux/slices/navbar'; +import { selectContextMenu } from '@/redux/slices/QuranReader/contextMenu'; +import { selectNotes } from '@/redux/slices/QuranReader/notes'; +import { selectLastReadVerseKey } from '@/redux/slices/QuranReader/readingTracker'; +import { + selectIsSidebarNavigationVisible, + setIsSidebarNavigationVisible, +} from '@/redux/slices/QuranReader/sidebarNavigation'; +import { getChapterData, getChapterReadingProgress } from '@/utils/chapter'; +import { logEvent } from '@/utils/eventLogger'; +import { getJuzNumberByHizb } from '@/utils/juz'; +import { toLocalizedNumber } from '@/utils/locale'; +import { isMobile } from '@/utils/responsive'; +import { getVerseNumberFromKey } from '@/utils/verse'; +import DataContext from 'src/contexts/DataContext'; +/** + * Custom hook to manage all state logic for the ContextMenu component + * @returns {object} An object containing state, data, translations, and event handlers for the ContextMenu + */ +const useContextMenuState = () => { + const dispatch = useDispatch(); + const chaptersData = useContext(DataContext); + const isSidebarNavigationVisible = useSelector(selectIsSidebarNavigationVisible); + const { t, lang } = useTranslation('common'); + const mushaf = useGetMushaf(); + const isSideBarVisible = useSelector(selectNotes, shallowEqual).isVisible; + const { isExpanded, showReadingPreferenceSwitcher: isReadingPreferenceSwitcherVisible } = + useSelector(selectContextMenu, shallowEqual); + + const { isActive } = useOnboarding(); + const { isVisible: isNavbarVisible } = useSelector(selectNavbar, shallowEqual); + + // Use the shared hook to debounce navbar visibility changes + const showNavbar = useDebounceNavbarVisibility(isNavbarVisible, isActive); + const showReadingPreferenceSwitcher = isReadingPreferenceSwitcherVisible && !isActive; + + const { verseKey, chapterId, page, hizb } = useSelector(selectLastReadVerseKey, shallowEqual); + + // Memoized values + const chapterData = useMemo(() => { + return chapterId ? getChapterData(chaptersData, chapterId) : null; + }, [chapterId, chaptersData]); + + const juzNumber = useMemo(() => { + return hizb ? toLocalizedNumber(getJuzNumberByHizb(Number(hizb)), lang) : null; + }, [hizb, lang]); + + const localizedHizb = useMemo(() => { + return toLocalizedNumber(Number(hizb), lang); + }, [hizb, lang]); + + // Localized page number + const localizedPageNumber = useMemo(() => { + return toLocalizedNumber(Number(page), lang); + }, [page, lang]); + + // Non-localized page number + const pageNumber = useMemo(() => { + return Number(page); + }, [page]); + + // Progress calculation + const progress = useMemo(() => { + if (!verseKey || !chapterData) return 0; + const verse = getVerseNumberFromKey(verseKey); + return getChapterReadingProgress(verse, chapterData.versesCount); + }, [verseKey, chapterData]); + + // Event handlers + const handleSidebarToggle = (e: React.MouseEvent) => { + logEvent(`sidebar_navigation_${isSidebarNavigationVisible ? 'close' : 'open'}_trigger`); + e.stopPropagation(); + + if (isSidebarNavigationVisible === 'auto') { + const shouldBeVisible = isMobile(); + dispatch(setIsSidebarNavigationVisible(shouldBeVisible)); + } else { + dispatch(setIsSidebarNavigationVisible(!isSidebarNavigationVisible)); + } + }; + + return { + // State + isSidebarNavigationVisible, + showNavbar, + showReadingPreferenceSwitcher, + isSideBarVisible, + isExpanded, + mushaf, + verseKey, + + // Data + chapterData, + juzNumber, + localizedHizb, + localizedPageNumber, + pageNumber, + progress, + + // Translations + t, + + // Event handlers + handleSidebarToggle, + }; +}; + +export default useContextMenuState; diff --git a/src/components/QuranReader/ContextMenu/index.tsx b/src/components/QuranReader/ContextMenu/index.tsx new file mode 100644 index 0000000000..f9df56a22a --- /dev/null +++ b/src/components/QuranReader/ContextMenu/index.tsx @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; + +import classNames from 'classnames'; + +import ReadingPreferenceSwitcher, { + ReadingPreferenceSwitcherType, +} from '../ReadingPreferenceSwitcher'; +import TajweedColors from '../TajweedBar/TajweedBar'; + +import ChapterNavigation from './components/ChapterNavigation'; +import MobileReadingTabs from './components/MobileReadingTabs'; +import PageInfo from './components/PageInfo'; +import ProgressBar from './components/ProgressBar'; +import SettingsButton from './components/SettingsButton'; +import useContextMenuState from './hooks/useContextMenuState'; +import styles from './styles/ContextMenu.module.scss'; + +import { SwitchSize, SwitchVariant } from '@/dls/Switch/Switch'; +import { Mushaf } from '@/types/QuranReader'; +import { isMobile } from '@/utils/responsive'; +import { getChapterNumberFromKey } from '@/utils/verse'; + +/** + * ContextMenu component for the Quran reader + * Displays chapter navigation, reading preferences, and page information + * @returns {JSX.Element|null} React component that renders the context menu UI with navigation, preferences, and page info, or null if data isn't loaded + */ +const ContextMenu: React.FC = (): JSX.Element | null => { + const { + // State + isSidebarNavigationVisible, + showNavbar, + isSideBarVisible, + isExpanded, + mushaf, + verseKey, + + // Data + chapterData, + juzNumber, + localizedHizb, + pageNumber, + progress, + + // Translations + t, + + // Event handlers + handleSidebarToggle, + } = useContextMenuState(); + + const isMobileView = useMemo(() => isMobile(), []); + const isMobileScrolledView = !showNavbar && isMobileView; + const isNotMobileOrScrolledView = !showNavbar || !isMobileView; + + // Early return if no verse key (SSR or first render) + if (!verseKey || !chapterData) { + return null; + } + + return ( +
+ {/* Page Information Section as its own row on mobile scrolled view */} + {isMobileScrolledView && ( +
+
+

+ +

+
+ )} + +
+ {/* Chapter Navigation Section */} +
+
+
+ + {showNavbar && } +
+
+
+ + {/* Page Information Section (default, not mobile scrolled view) */} + {!isMobileScrolledView && ( +
+
+

+ +

+
+ )} + + {/* Reading Preference Section */} +
+
+ + {(!isMobileView || !showNavbar) && ( + + )} +
+
+
+ + {/* Mobile-specific tabs for switching between reading preferences + Appears only on mobile breakpoints when the navbar is visible */} + {showNavbar && } + + {/* Tajweed colors bar will only show when tajweed mushaf enabled */} + {mushaf === Mushaf.QCFTajweedV4 && } + + {/* Reading progress bar */} + {isNotMobileOrScrolledView && } +
+ ); +}; + +export default ContextMenu; diff --git a/src/components/QuranReader/ContextMenu/styles/ContextMenu.module.scss b/src/components/QuranReader/ContextMenu/styles/ContextMenu.module.scss new file mode 100644 index 0000000000..4ba66bf5f2 --- /dev/null +++ b/src/components/QuranReader/ContextMenu/styles/ContextMenu.module.scss @@ -0,0 +1,263 @@ +@use 'src/styles/constants'; +@use 'src/styles/breakpoints'; + +$sections-container-height: 2rem; +$pixel: 1px; + +.container { + background: var(--color-background-elevated); + color: var(--color-text-default); + text-align: center; + position: fixed; + z-index: var(--z-index-sticky); + transition: transform var(--transition-regular); + inline-size: 100%; + inset-block-start: calc($pixel * -1); + min-block-size: var(--context-menu-container-height); + will-change: transform; + box-sizing: border-box; +} + +.hide { + display: none; + block-size: 0; + visibility: hidden; +} + +.visibleContainer { + transform: translate3d(0, var(--navbar-container-height), 0); + // https://ptgamr.github.io/2014-09-13-translate3d-vs-translate-performance/ +} + +.withVisibleBanner { + @include breakpoints.smallerThanTablet { + // TODO: we should add the banner height here if it's shown + // transform: translate3d(0, calc(var(--navbar-container-height) + var(--banner-height)), 0); + transform: translate3d(0, calc(var(--navbar-container-height)), 0); + } +} + +.expandedContainer { + min-block-size: var(--context-menu-container-height); +} + +.withVisibleSideBar { + @include breakpoints.tablet { + margin-inline-end: constants.$notes-side-bar-desktop-width; + inline-size: calc(100% - #{constants.$notes-side-bar-desktop-width}); + } +} + +.chapter { + font-size: var(--font-size-large); + margin-inline: var(--spacing-large); +} + +.bold { + font-weight: var(--font-weight-bold); +} + +.sectionsContainer { + block-size: $sections-container-height; + padding-block: calc(var(--spacing-medium) / 2); + display: flex; + align-items: center; + padding-inline: var(--spacing-medium3-px); + z-index: var(--z-index-default); + position: relative; + background: var(--color-background-elevated); + + @include breakpoints.tablet { + padding-inline: var(--spacing-xlarge-px); + } + + @include breakpoints.smallerThanTablet { + padding-inline: var(--spacing-medium3-px); + } +} + +.section { + flex: 1; +} + +.pageInfoCustomContainer { + @include breakpoints.smallerThanTablet { + visibility: hidden; + display: none; + block-size: 0; + visibility: hidden; + } +} + +.pageInfoCustomContainerMobileScrolled { + display: flex; + align-items: center; + justify-content: space-between !important; + padding-inline: calc(1.3 * var(--spacing-large)); + padding-block: calc(var(--spacing-medium) * 0.75); +} + +.readingPreferenceSection { + div { + margin-inline-end: 0; + padding-inline-end: 0; + } +} + +.readingPreferenceContainer { + display: flex; + align-items: center; + column-gap: var(--spacing-medium); +} + +.chapterNavigationRow { + align-items: center; + flex-direction: row; +} + +.chapterNavigationWrapper { + display: flex; + align-items: center; + column-gap: var(--spacing-medium); + inline-size: 100%; + justify-content: space-between; + + @include breakpoints.tablet { + padding-inline: var(--spacing-medium2-px); + } +} + +.visibleContainer .chapterNavigationWrapper { + @include breakpoints.smallerThanTablet { + inline-size: 90vw; + } +} + +.settingsNextToChapter { + display: inline-flex; + + @include breakpoints.tablet { + display: none; + } + + @include breakpoints.smallerThanTablet { + display: inline-flex; + } +} + +.settingsNextToSwitcher { + display: inline-flex; +} + +.hideReadingPreferenceSectionOnMobile { + @include breakpoints.smallerThanTablet { + visibility: hidden; + display: none; + block-size: 0; + visibility: hidden; + } +} + +.alignCenter { + text-align: center; +} + +.row { + display: flex; + justify-content: space-between; + flex-direction: column; + inline-size: 100%; +} + +.pageInfoContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + column-gap: var(--spacing-xsmall); + color: var(--color-text-faded-new); + background: var(--color-background-elevated); + z-index: var(--z-index-default); +} + +.bookmarkButton { + background: transparent; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + + svg { + inline-size: 16px; + block-size: 16px; + } +} + +.unbookmarkedIcon { + color: var(--color-text-faded-new); + + &:hover { + color: var(--color-text-default); + } +} + +.bookmarkedIcon { + color: var(--color-text-default); +} + +.primaryInfo { + display: flex; + align-items: center; + column-gap: var(--spacing-xxsmall-px); + font-weight: var(--font-weight-semibold); +} + +.secondaryInfo { + font-weight: var(--font-weight-normal); +} + +.surahName { + display: flex; + align-items: center; + cursor: pointer; +} + +.chevronIconContainer { + display: inline-flex; + align-items: center; + justify-content: center; + margin-inline-start: var(--spacing-xxsmall); + box-sizing: border-box; +} + +.rotate180 { + transition: var(--transition-fast); + transform: rotate(180deg); +} + +.rotateAuto { + transition: var(--transition-fast); + transform: rotate(0); + + @include breakpoints.tablet { + transition: var(--transition-fast); + transform: rotate(180deg); + } +} + +.disabledOnMobile { + pointer-events: none; + + @include breakpoints.tablet { + pointer-events: inherit; + } +} + +.chapterInteractiveSpan { + cursor: pointer; + display: flex; + align-items: center; + font-weight: var(--font-weight-semibold); +} diff --git a/src/components/QuranReader/ContextMenu/styles/MobileReadingTabs.module.scss b/src/components/QuranReader/ContextMenu/styles/MobileReadingTabs.module.scss new file mode 100644 index 0000000000..f7f86f4fba --- /dev/null +++ b/src/components/QuranReader/ContextMenu/styles/MobileReadingTabs.module.scss @@ -0,0 +1,50 @@ +@use "src/styles/constants"; +@use "src/styles/breakpoints"; +@use "src/styles/theme"; + +.container { + width: 100%; + display: none; // Hidden by default + + // Only show on mobile when navbar is visible + @include breakpoints.smallerThanTablet { + display: block; + } +} + +.tabsContainer { + width: 100%; + display: flex; + justify-content: space-between; + border-bottom: 1px solid var(--color-borders-hairline); + background-color: var(--color-background-elevated-new); + block-size: var(--mobile-reading-mode-tabs-height); +} + +.tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: var(--font-size-small); + padding-inline: var(--spacing-medium); + padding-top: var(--spacing-medium); + padding-bottom: var(--spacing-large); + font-weight: var(--font-weight-medium); + color: var(--color-text-faded-new); + width: 50%; + border-bottom: 2px solid var(--color-separators-ayah-level); + cursor: pointer; + transition: all 0.3s ease; + + span { + margin-left: var(--spacing-xsmall); + } +} + +.selectedTab { + color: var(--color-success-medium); + border-bottom: 2px solid var(--color-success-medium); + font-weight: var(--font-weight-semibold); +} diff --git a/src/components/QuranReader/ContextMenu/styles/ProgressBar.module.scss b/src/components/QuranReader/ContextMenu/styles/ProgressBar.module.scss new file mode 100644 index 0000000000..a8478a939d --- /dev/null +++ b/src/components/QuranReader/ContextMenu/styles/ProgressBar.module.scss @@ -0,0 +1,41 @@ +@use "src/styles/breakpoints"; + +.container { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + z-index: var(--z-index-sticky); +} + +.track { + width: 100%; + height: 3px; + background-color: var(--color-separators-new); + position: relative; +} + +.indicator { + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: var(--color-success-deep); + transition: width var(--transition-fast); + transition-timing-function: linear; + min-width: 0; +} + +.hide { + display: none; + visibility: hidden; +} + +// Ensure it works properly on mobile +@include breakpoints.smallerThanTablet { + .container { + bottom: 0; + transform: none; + } +} diff --git a/src/components/QuranReader/EndOfScrollingControls/ChapterControls.tsx b/src/components/QuranReader/EndOfScrollingControls/ChapterControls.tsx index df6d107c73..540a060d07 100644 --- a/src/components/QuranReader/EndOfScrollingControls/ChapterControls.tsx +++ b/src/components/QuranReader/EndOfScrollingControls/ChapterControls.tsx @@ -37,6 +37,7 @@ const ChapterControls: React.FC = ({ initialData }) => { <> {!isFirstSurah(chapterNumber, isReadingByRevelationOrder) && ( )} {!isLastSurah(chapterNumber, isReadingByRevelationOrder) && ( ) : ( )}
diff --git a/src/components/QuranReader/QuranReader.module.scss b/src/components/QuranReader/QuranReader.module.scss index e614338236..b6b61e127e 100644 --- a/src/components/QuranReader/QuranReader.module.scss +++ b/src/components/QuranReader/QuranReader.module.scss @@ -1,8 +1,9 @@ -@use "src/styles/constants"; -@use "src/styles/breakpoints"; -@use "src/styles/utility"; +@use 'src/styles/constants'; +@use 'src/styles/breakpoints'; +@use 'src/styles/utility'; $quran-reader-padding: 3rem; +$quran-reader-padding-mobile: 4.5rem; $virtual-scrolling-height-bandage: calc( 4 * var(--spacing-mega) ); // library react-virtuoso's inline `height` is less than the required `height`. @@ -18,15 +19,15 @@ $virtual-scrolling-height-bandage: calc( } .readingView { - min-height: 100vh; + min-block-size: 100vh; @include breakpoints.smallerThanTablet { - width: 85%; + inline-size: 85%; } } .loading { text-align: center; - max-width: 80%; + max-inline-size: 80%; margin-block-start: var(--spacing-medium); margin-block-end: var(--spacing-medium); margin-inline-start: auto; @@ -34,16 +35,26 @@ $virtual-scrolling-height-bandage: calc( } .container { + @include breakpoints.smallerThanTablet { + // Add banner height since banner is not absolute on mobile + padding-block-start: calc($quran-reader-padding-mobile + var(--banner-height)); + } padding-block-start: $quran-reader-padding; padding-inline-start: 0; padding-inline-end: 0; - background-color: var(--color-background-default); + background-color: var(--color-background-elevated-new); background-image: var(--color-background-lighten); padding-block-end: $virtual-scrolling-height-bandage; @include breakpoints.tablet { transition: var(--transition-regular); margin-inline-end: 0; } + + @include breakpoints.smallerThanTablet { + // TODO: we should add the banner height here if it's shown + // padding-block-start: calc($quran-reader-padding + var(--mobile-reading-mode-tabs-height) + var(--banner-height)); + padding-block-start: calc($quran-reader-padding + var(--mobile-reading-mode-tabs-height)); + } } .withVisibleSideBar { diff --git a/src/components/QuranReader/QuranReaderView.tsx b/src/components/QuranReader/QuranReaderView.tsx index 4b67edd556..138c0720fc 100644 --- a/src/components/QuranReader/QuranReaderView.tsx +++ b/src/components/QuranReader/QuranReaderView.tsx @@ -4,7 +4,6 @@ import React from 'react'; import dynamic from 'next/dynamic'; import useSyncReadingProgress from './hooks/useSyncReadingProgress'; -import ReadingPreferenceSwitcher from './ReadingPreferenceSwitcher'; import TranslationView from './TranslationView'; import QuranReaderStyles from '@/redux/types/QuranReaderStyles'; @@ -34,28 +33,22 @@ const QuranReaderView: React.FC = ({ if (isReadingPreference) { return ( - <> - - - - ); - } - - return ( - <> - - - + ); + } + + return ( + ); }; diff --git a/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreference.module.scss b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreference.module.scss index e5bc67c3e6..48fed9df9c 100644 --- a/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreference.module.scss +++ b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreference.module.scss @@ -1,10 +1,17 @@ @use "src/styles/breakpoints"; +@use "src/styles/theme"; .container { display: flex; align-items: center; justify-content: center; width: 100%; + transition: all 0.3s ease; + cursor: pointer; + + &:hover .unselected { + opacity: 0.9; + } } .spinner { @@ -12,7 +19,8 @@ } .preferenceTextContainer { - padding-inline-start: var(--spacing-xsmall); + padding-inline-start: calc(var(--spacing-xsmall) / 2); + transition: opacity 0.3s ease; } .iconContainer { @@ -29,3 +37,119 @@ } } } + +.readingIcon, +.translationIcon { + transition: all 0.3s ease; + border-radius: 2px; + + rect, + path { + transition: all 0.3s ease; + } +} + +.selected { + opacity: 1; + + rect:first-child { + fill: var(--color-primary-medium, #272727); + stroke: var(--color-primary-medbutium, #272727); + } + + path { + fill: white; + } + + @include theme.dark { + rect:first-child { + fill: var(--color-primary-medium, #272727); + stroke: var(--color-primary-medium, #272727); + } + + path { + fill: var(--color-background-elevated-new, #1f2125); + } + } + + @include theme.sepia { + rect:first-child { + fill: var(--color-primary-medium, #000000); + stroke: var(--color-primary-medium, #000000); + } + + path { + fill: white; + } + } +} + +.successVariant { + opacity: 1; + + rect:first-child { + fill: var(--color-success-medium); + stroke: var(--color-success-medium); + } + + path { + fill: white; + } + + @include theme.dark { + rect:first-child { + fill: var(--color-success-medium); + stroke: var(--color-success-medium); + } + + path { + fill: var(--color-background-elevated-new, #1f2125); + } + } + + @include theme.sepia { + rect:first-child { + fill: var(--color-success-medium); + stroke: var(--color-success-medium); + } + + path { + fill: white; + } + } +} + +.unselected { + opacity: 0.7; + + rect:first-child { + fill: none; + stroke: #666666; + } + + path { + fill: #666666; + } + + @include theme.dark { + rect:first-child { + fill: none; + stroke: var(--color-text-faded-new, #dee2e6); + } + + path { + fill: var(--color-text-faded-new, #dee2e6); + } + } + + @include theme.sepia { + rect:first-child { + fill: none; + stroke: #666666; + } + + path { + fill: #666666; + } + } +} diff --git a/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceIcon.tsx b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceIcon.tsx new file mode 100644 index 0000000000..65a8fe4bdd --- /dev/null +++ b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceIcon.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import styles from './ReadingPreference.module.scss'; + +import ReadingIcon from '@/public/icons/reading.svg'; +import TranslationIcon from '@/public/icons/translation-mode.svg'; +import { ReadingPreference } from '@/types/QuranReader'; + +/** + * Props for the ReadingPreferenceIcon component + */ +export interface ReadingPreferenceIconProps { + /** The currently active reading preference in the application */ + currentReadingPreference: ReadingPreference; + /** The reading preference option this component represents */ + optionReadingPreference: ReadingPreference; + /** Whether to use success color variant for selected state */ + useSuccessVariant?: boolean; +} + +/** + * A component that returns the appropriate icon based on the current reading preference and the option being rendered + * + * @returns {JSX.Element} The appropriate icon component with transition support + */ +function ReadingPreferenceIcon({ + currentReadingPreference, + optionReadingPreference, + useSuccessVariant = false, +}: ReadingPreferenceIconProps): JSX.Element { + // Determine if this option is currently selected + const isSelected = currentReadingPreference === optionReadingPreference; + + // Determine the style class based on selection state and variant + const getStyleClass = () => { + if (!isSelected) return styles.unselected; + return useSuccessVariant ? styles.successVariant : styles.selected; + }; + + // Return the appropriate icon component based on the option type + return optionReadingPreference === ReadingPreference.Reading ? ( + + ) : ( + + ); +} + +/** + * For backward compatibility, maintain the function API + * + * @param {ReadingPreferenceIconProps} props - Component props + * @returns {JSX.Element} The ReadingPreferenceIcon component + */ +export function getReadingPreferenceIcon(props: ReadingPreferenceIconProps): JSX.Element { + return ; +} + +export default ReadingPreferenceIcon; diff --git a/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceOption.tsx b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceOption.tsx index 27bcc26870..5da9274286 100644 --- a/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceOption.tsx +++ b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceOption.tsx @@ -3,35 +3,48 @@ import React from 'react'; import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; -import styles from '@/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreference.module.scss'; +import styles from './ReadingPreference.module.scss'; +import ReadingPreferenceIcon from './ReadingPreferenceIcon'; + import Spinner from '@/dls/Spinner/Spinner'; -import BookIcon from '@/icons/book.svg'; -import ReaderIcon from '@/icons/reader.svg'; import { ReadingPreference } from '@/types/QuranReader'; -type Props = { +/** + * Props for the ReadingPreferenceOption component + */ +interface ReadingPreferenceOptionProps { + /** The currently active reading preference in the application */ readingPreference: ReadingPreference; + /** The reading preference option this component represents */ selectedReadingPreference: ReadingPreference; + /** Whether to show only icons without text labels */ isIconsOnly?: boolean; + /** Whether the reading preference is currently being changed/loaded */ isLoading: boolean; -}; - -export const readingPreferenceIcons = { - [ReadingPreference.Reading]: , - [ReadingPreference.Translation]: , -}; +} -const LoadingSwitcher: React.FC = ({ +/** + * Component that displays a reading preference option with loading state support + * + * This component renders either a loading spinner (when the option is being loaded) + * or the appropriate icon for the reading preference option. It also optionally + * displays a text label for the option. + * + * @param {ReadingPreferenceOptionProps} props - Component props + * @returns {JSX.Element} The reading preference option with appropriate icon and loading state + */ +function ReadingPreferenceOption({ readingPreference, selectedReadingPreference, isIconsOnly = false, isLoading, -}) => { +}: ReadingPreferenceOptionProps): JSX.Element { const { t } = useTranslation('common'); - return isLoading && readingPreference === selectedReadingPreference ? ( + + return isLoading ? (
- - + + {!isIconsOnly && ( @@ -42,15 +55,23 @@ const LoadingSwitcher: React.FC = ({ ) : (
- {readingPreferenceIcons[selectedReadingPreference]} + {!isIconsOnly && ( - + {t(`reading-preference.${selectedReadingPreference}`)} )}
); -}; +} -export default LoadingSwitcher; +export default ReadingPreferenceOption; diff --git a/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceSwitcher.module.scss b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceSwitcher.module.scss index fc6eb6e8d7..c5c24ed794 100644 --- a/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceSwitcher.module.scss +++ b/src/components/QuranReader/ReadingPreferenceSwitcher/ReadingPreferenceSwitcher.module.scss @@ -1,6 +1,8 @@ $readingPreferenceMaxWidth: calc(20 * var(--spacing-large)); -$contextMenuReadingPreferenceMaxWidth: calc(10 * var(--spacing-large)); +$contextMenuReadingPreferenceMaxWidth: 227px; +$contextMenuReadingPreferenceIconOnlyWidth: 73px; $readingPreferenceSwitcherContainerTopPadding: var(--spacing-large); + .container { max-width: $readingPreferenceMaxWidth; margin-inline-start: auto; @@ -21,5 +23,9 @@ $readingPreferenceSwitcherContainerTopPadding: var(--spacing-large); } .contextMenuContainer { - max-width: $contextMenuReadingPreferenceMaxWidth; + width: $contextMenuReadingPreferenceMaxWidth; +} + +.contextMenuIconOnlyContainer { + width: $contextMenuReadingPreferenceIconOnlyWidth; } diff --git a/src/components/QuranReader/ReadingPreferenceSwitcher/index.tsx b/src/components/QuranReader/ReadingPreferenceSwitcher/index.tsx index e5f98d05de..fce34dcf18 100644 --- a/src/components/QuranReader/ReadingPreferenceSwitcher/index.tsx +++ b/src/components/QuranReader/ReadingPreferenceSwitcher/index.tsx @@ -1,13 +1,17 @@ +import React from 'react'; + import classNames from 'classnames'; import { useRouter } from 'next/router'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; -import LoadingSwitcher from './ReadingPreferenceOption'; +import ReadingPreferenceOption from './ReadingPreferenceOption'; import styles from './ReadingPreferenceSwitcher.module.scss'; -import Switch, { SwitchSize } from '@/dls/Switch/Switch'; +import Switch, { SwitchSize, SwitchVariant } from '@/dls/Switch/Switch'; import usePersistPreferenceGroup from '@/hooks/auth/usePersistPreferenceGroup'; import useGetMushaf from '@/hooks/useGetMushaf'; +import useScrollRestoration from '@/hooks/useScrollRestoration'; +import { setLockVisibilityState } from '@/redux/slices/navbar'; import { selectReadingPreferences, setReadingPreference, @@ -17,37 +21,65 @@ import { logValueChange } from '@/utils/eventLogger'; import PreferenceGroup from 'types/auth/PreferenceGroup'; import { Mushaf, ReadingPreference } from 'types/QuranReader'; +/** + * Enum for the different contexts where the reading preference switcher can be used + */ export enum ReadingPreferenceSwitcherType { SurahHeader = 'surah_header', ContextMenu = 'context_menu', } -interface Props { - size?: SwitchSize; +/** + * Props for the ReadingPreferenceSwitcher component + */ +type Props = { + type: ReadingPreferenceSwitcherType; isIconsOnly?: boolean; - type?: ReadingPreferenceSwitcherType; -} + size?: SwitchSize; + variant?: SwitchVariant; +}; -const ReadingPreferenceSwitcher: React.FC = ({ - size, +/** + * Component for switching between different reading preferences (Translation/Reading) + * + * @param {object} props - Component props + * @param {SwitchSize} [props.size] - Size of the switch component + * @param {boolean} [props.isIconsOnly] - Whether to show only icons without text + * @param {ReadingPreferenceSwitcherType} [props.type] - The context where this switcher is being used + * @param {SwitchVariant} [props.variant] - Variant of the switch component + * @returns {JSX.Element} React component for switching reading preferences + */ +const ReadingPreferenceSwitcher = ({ + type, isIconsOnly = false, - type = ReadingPreferenceSwitcherType.SurahHeader, -}) => { + size = SwitchSize.Normal, + variant = SwitchVariant.Default, +}: Props) => { + // Redux state const readingPreferences = useSelector(selectReadingPreferences); const lastReadVerseKey = useSelector(selectLastReadVerseKey); - const lastReadVerse = lastReadVerseKey.verseKey?.split(':')[1]; const { readingPreference } = readingPreferences; + + // Hooks + const router = useRouter(); + const mushaf = useGetMushaf(); + const dispatch = useDispatch(); const { actions: { onSettingsChange }, isLoading, } = usePersistPreferenceGroup(); - const router = useRouter(); - const mushaf = useGetMushaf(); - const readingPreferencesOptions = [ + // Extract verse number from the last read verse key + const lastReadVerse = lastReadVerseKey.verseKey?.split(':')[1]; + + /** + * Generate the switch options for reading preferences + * @returns {Array<{name: JSX.Element, value: ReadingPreference}>} Array of switch options + */ + const readingPreferencesOptions = (): Array<{ name: JSX.Element; value: ReadingPreference }> => [ { name: ( - = ({ }, { name: ( - = ({ }, ]; - const onViewSwitched = (view: ReadingPreference) => { - logValueChange(`${type}_reading_preference`, readingPreference, view); + // Use the shared scroll restoration hook + const { restoreScrollPosition } = useScrollRestoration(); + /** + * Handle switching between reading preferences + * + * @param {ReadingPreference} view - The new reading preference to switch to + */ + /** + * Prepares URL parameters for the reading preference change + * + * @returns {object} URL object with query parameters + */ + const prepareUrlParams = () => { + // Prepare URL parameters const newQueryParams = { ...router.query }; - // Track `startingVerse` once we're past the start of the page so we can - // continue from the same ayah when switching views. Without the > 1 check, - // switching views at the start of the page causes unnecessary scrolls - + // Handle starting verse based on context if (type === ReadingPreferenceSwitcherType.SurahHeader) { + // In SurahHeader, we don't need to track the verse delete newQueryParams.startingVerse; } else if (parseInt(lastReadVerse, 10) > 1) { + // In ContextMenu, we track the verse if we're not at the beginning newQueryParams.startingVerse = lastReadVerse; } - const newUrlObject = { + // Create the new URL object + return { pathname: router.pathname, query: newQueryParams, }; + }; - router.replace(newUrlObject, null, { shallow: true }).then(() => { - onSettingsChange( - 'readingPreference', - view, - setReadingPreference(view), - setReadingPreference(readingPreference), - PreferenceGroup.READING, - ); + /** + * Handle the post-navigation tasks after the URL has been updated + * + * @param {ReadingPreference} view - The new reading preference + * @param {number} scrollPosition - The scroll position to maintain + * @param {boolean} isTranslationTab - Whether this is the translation tab + */ + const handlePostNavigation = ( + view: ReadingPreference, + scrollPosition: number, + isTranslationTab: boolean, + ) => { + // Update reading preference in Redux + onSettingsChange( + 'readingPreference', + view, + setReadingPreference(view), + setReadingPreference(readingPreference), + PreferenceGroup.READING, + ); + + // Use the shared hook to restore scroll position and handle completion + restoreScrollPosition(scrollPosition, isTranslationTab, () => { + dispatch(setLockVisibilityState(false)); }); }; + const onViewSwitched = (view: ReadingPreference) => { + // Log the change event + logValueChange(`${type}_reading_preference`, readingPreference, view); + + // Lock navbar visibility state to prevent flickering during tab switching + dispatch(setLockVisibilityState(true)); + + // Save current scroll position + const scrollPosition = window.scrollY; + + // Check if this is the translation tab which tends to cause scrolling + const isTranslationTab = view === ReadingPreference.Translation; + + // Get URL parameters for the navigation + const newUrlObject = prepareUrlParams(); + + // Update the URL and then handle post-navigation tasks + router.replace(newUrlObject, null, { shallow: true, scroll: false }).then(() => { + handlePostNavigation(view, scrollPosition, isTranslationTab); + }); + }; + + // Determine container class names based on context and mushaf type + const containerClassNames = classNames(styles.container, { + [styles.surahHeaderContainer]: type === ReadingPreferenceSwitcherType.SurahHeader, + [styles.contextMenuContainer]: + type === ReadingPreferenceSwitcherType.ContextMenu && !isIconsOnly, + [styles.contextMenuIconOnlyContainer]: + type === ReadingPreferenceSwitcherType.ContextMenu && isIconsOnly, + [styles.tajweedMushaf]: + mushaf === Mushaf.QCFTajweedV4 && type === ReadingPreferenceSwitcherType.SurahHeader, + }); + return ( -
+
); }; + export default ReadingPreferenceSwitcher; diff --git a/src/components/QuranReader/ReadingView/Buttons/QuestionsButton/QuestionsButton.module.scss b/src/components/QuranReader/ReadingView/Buttons/QuestionsButton/QuestionsButton.module.scss deleted file mode 100644 index 888ba1b7d1..0000000000 --- a/src/components/QuranReader/ReadingView/Buttons/QuestionsButton/QuestionsButton.module.scss +++ /dev/null @@ -1,6 +0,0 @@ -@use "src/components/QuestionAndAnswer/Pill/shared.module.scss" as shared; - -.container { - color: var(--text-color) !important; - @extend %explore_answers; -} diff --git a/src/components/QuranReader/ReadingView/Buttons/QuestionsButton/index.tsx b/src/components/QuranReader/ReadingView/Buttons/QuestionsButton/index.tsx deleted file mode 100644 index b728f77003..0000000000 --- a/src/components/QuranReader/ReadingView/Buttons/QuestionsButton/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { useState } from 'react'; - -import classNames from 'classnames'; -import { useRouter } from 'next/router'; -import useTranslation from 'next-translate/useTranslation'; - -import styles from './QuestionsButton.module.scss'; - -import QuestionsModal from '@/components/QuestionAndAnswer/QuestionsModal'; -import { usePageQuestions } from '@/components/QuranReader/ReadingView/context/PageQuestionsContext'; -import translationViewStyles from '@/components/QuranReader/TranslationView/TranslationViewCell.module.scss'; -import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; -import ScholarsSayIcon from '@/icons/lighbulb.svg'; -import { logButtonClick, logEvent } from '@/utils/eventLogger'; -import { fakeNavigate, getVerseAnswersNavigationUrl } from '@/utils/navigation'; - -interface Props { - verseKey: string; - onActionTriggered?: () => void; -} - -const QuestionsButton: React.FC = ({ verseKey, onActionTriggered }) => { - const pageQuestionsCount = usePageQuestions(); - const { t, lang } = useTranslation('quran-reader'); - const router = useRouter(); - const [isContentModalOpen, setIsContentModalOpen] = useState(false); - const hasQuestions = pageQuestionsCount && pageQuestionsCount[verseKey] > 0; - const onButtonClicked = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - logButtonClick(`reading_view_verse_actions_menu_questions`); - setIsContentModalOpen(true); - fakeNavigate(getVerseAnswersNavigationUrl(verseKey), lang); - }; - - const onModalClose = () => { - logEvent('reading_view_questions_modal_close'); - setIsContentModalOpen(false); - fakeNavigate(router.asPath, router.locale); - if (onActionTriggered) { - onActionTriggered(); - } - }; - - const onModalClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - }; - - if (hasQuestions) { - return ( - <> - - - - ); - } - - return null; -}; - -export default QuestionsButton; diff --git a/src/components/QuranReader/ReadingView/CopyButton/index.tsx b/src/components/QuranReader/ReadingView/CopyButton/index.tsx index a6fa557620..fcbf75a821 100644 --- a/src/components/QuranReader/ReadingView/CopyButton/index.tsx +++ b/src/components/QuranReader/ReadingView/CopyButton/index.tsx @@ -8,6 +8,7 @@ import styles from '@/components/QuranReader/TranslationView/TranslationViewCell import copyVerse from '@/components/Verse/AdvancedCopy/utils/copyVerse'; import DataContext from '@/contexts/DataContext'; import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; import CopyIcon from '@/icons/copy.svg'; import { selectSelectedTranslations } from '@/redux/slices/QuranReader/translations'; @@ -51,9 +52,7 @@ const CopyButton: React.FC = ({ if (isCopied) { timeoutId = setTimeout(() => { setIsCopied(false); - if (onActionTriggered) { - onActionTriggered(); - } + onActionTriggered?.(); }, RESET_ACTION_TEXT_TIMEOUT_MS); } return () => { @@ -98,12 +97,15 @@ const CopyButton: React.FC = ({ tooltip={isCopied ? t('copied') : t('quran-reader:copy-verse')} shouldFlipOnRTL={false} shape={ButtonShape.Circle} - className={classNames(styles.iconContainer, styles.verseAction, { - [styles.fadedVerseAction]: isTranslationView, - })} + className={classNames(styles.iconContainer, styles.verseAction)} > - + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> ); diff --git a/src/components/QuranReader/ReadingView/Line.tsx b/src/components/QuranReader/ReadingView/Line.tsx index a66727a17e..89c81fbc5c 100644 --- a/src/components/QuranReader/ReadingView/Line.tsx +++ b/src/components/QuranReader/ReadingView/Line.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, memo, useContext, RefObject } from 'react'; +import { memo, RefObject, useContext, useEffect } from 'react'; import { useSelector as useXstateSelector } from '@xstate/react'; import classNames from 'classnames'; @@ -27,9 +27,17 @@ export type LineProps = { quranReaderStyles: QuranReaderStyles; pageIndex: number; lineIndex: number; + bookmarksRangeUrl: string | null; }; -const Line = ({ lineKey, words, isBigTextLayout, pageIndex, lineIndex }: LineProps) => { +const Line = ({ + lineKey, + words, + isBigTextLayout, + pageIndex, + lineIndex, + bookmarksRangeUrl, +}: LineProps) => { const audioService = useContext(AudioPlayerMachineContext); const isHighlighted = useXstateSelector(audioService, (state) => { const { surah, ayahNumber } = state.context; @@ -57,6 +65,8 @@ const Line = ({ lineKey, words, isBigTextLayout, pageIndex, lineIndex }: LinePro const firstWordData = getWordDataByLocation(words[0].location); const shouldShowChapterHeader = firstWordData[1] === '1' && firstWordData[2] === '1'; const isWordByWordLayout = showWordByWordTranslation || showWordByWordTransliteration; + const translationsCount = words[0].verse?.translationsCount; + const translationsLabel = words[0].verse?.translationsLabel; return (
{shouldShowChapterHeader && ( )}
@@ -98,8 +112,8 @@ const Line = ({ lineKey, words, isBigTextLayout, pageIndex, lineIndex }: LinePro * we need to use custom comparing logic: * * 1. Check if the line keys are the same. - * 2. Check if the number of words are the same. - * 3. Check if isBigTextLayout values are the same. + * 2. Check if isBigTextLayout values are the same. + * 3. Check if bookmarksRangeUrl values are the same. * 4. Check if the font changed. * * If the above conditions are met, it's safe to assume that the result @@ -112,6 +126,7 @@ const Line = ({ lineKey, words, isBigTextLayout, pageIndex, lineIndex }: LinePro const areLinesEqual = (prevProps: LineProps, nextProps: LineProps): boolean => prevProps.lineKey === nextProps.lineKey && prevProps.isBigTextLayout === nextProps.isBigTextLayout && + prevProps.bookmarksRangeUrl === nextProps.bookmarksRangeUrl && !verseFontChanged( prevProps.quranReaderStyles, nextProps.quranReaderStyles, diff --git a/src/components/QuranReader/ReadingView/Page.tsx b/src/components/QuranReader/ReadingView/Page.tsx index 1d9a8ca55f..0bfe990ab7 100644 --- a/src/components/QuranReader/ReadingView/Page.tsx +++ b/src/components/QuranReader/ReadingView/Page.tsx @@ -22,21 +22,26 @@ type PageProps = { pageNumber: number; quranReaderStyles: QuranReaderStyles; pageIndex: number; + bookmarksRangeUrl: string | null; }; -const Page = ({ verses, pageNumber, quranReaderStyles, pageIndex }: PageProps) => { - const { data: pageVersesQuestionsCount } = useCountRangeQuestions( - verses && verses.length > 0 +const Page = ({ + verses, + pageNumber, + quranReaderStyles, + pageIndex, + bookmarksRangeUrl, +}: PageProps) => { + const { data: pageVersesQuestionsData } = useCountRangeQuestions( + verses?.length > 0 ? { from: verses?.[0].verseKey, to: verses?.[verses.length - 1].verseKey, } : null, ); - const lines = useMemo( - () => (verses && verses.length ? groupLinesByVerses(verses) : {}), - [verses], - ); + + const lines = useMemo(() => (verses?.length > 0 ? groupLinesByVerses(verses) : {}), [verses]); const { quranTextFontScale, quranFont, mushafLines } = quranReaderStyles; const { showWordByWordTranslation, showWordByWordTransliteration } = useSelector( selectInlineDisplayWordByWordPreferences, @@ -47,7 +52,7 @@ const Page = ({ verses, pageNumber, quranReaderStyles, pageIndex }: PageProps) = const isFontLoaded = useIsFontLoaded(pageNumber, quranFont); return ( - +
))} diff --git a/src/components/QuranReader/ReadingView/PageContainer.tsx b/src/components/QuranReader/ReadingView/PageContainer.tsx index 1fb54fb6fd..6815669197 100644 --- a/src/components/QuranReader/ReadingView/PageContainer.tsx +++ b/src/components/QuranReader/ReadingView/PageContainer.tsx @@ -9,9 +9,14 @@ import Page from './Page'; import ReadingViewSkeleton from './ReadingViewSkeleton'; import { getReaderViewRequestKey, verseFetcher } from '@/components/QuranReader/api'; +import useIsLoggedIn from '@/hooks/auth/useIsLoggedIn'; import useIsUsingDefaultSettings from '@/hooks/useIsUsingDefaultSettings'; import { selectIsPersistGateHydrationComplete } from '@/redux/slices/persistGateHydration'; +import { selectSelectedTranslations } from '@/redux/slices/QuranReader/translations'; import QuranReaderStyles from '@/redux/types/QuranReaderStyles'; +import { getMushafId } from '@/utils/api'; +import { areArraysEqual } from '@/utils/array'; +import { makeBookmarksRangeUrl } from '@/utils/auth/apiPaths'; import { VersesResponse } from 'types/ApiResponses'; import LookupRecord from 'types/LookupRecord'; import Verse from 'types/Verse'; @@ -94,6 +99,7 @@ const PageContainer: React.FC = ({ * and consistent throughout the component lifecycle, preventing cache key mismatches. */ const isPersistGateHydrationComplete = useSelector(selectIsPersistGateHydrationComplete); + const { isLoggedIn } = useIsLoggedIn(); const pageNumber = useMemo( () => getPageNumberByPageIndex(pageIndex, pagesVersesRange), @@ -104,6 +110,8 @@ const PageContainer: React.FC = ({ [initialData.verses, pageIndex, pageNumber], ); + const selectedTranslations = useSelector(selectSelectedTranslations, areArraysEqual) as number[]; + const isUsingDefaultSettings = useIsUsingDefaultSettings(); const shouldUseInitialData = pageIndex === 0 && isUsingDefaultSettings; @@ -123,6 +131,7 @@ const PageContainer: React.FC = ({ reciter: reciterId, locale: lang, wordByWordLocale, + selectedTranslations, }) : null; @@ -147,6 +156,18 @@ const PageContainer: React.FC = ({ } }, [pageNumber, setMushafPageToVersesMap, effectiveVerses]); + // Calculate bookmarks range URL for bulk fetching (memoized to prevent unnecessary recalculations) + const bookmarksRangeUrl = useMemo(() => { + if (!effectiveVerses?.length || !isLoggedIn) return null; + const mushafId = getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf; + return makeBookmarksRangeUrl( + mushafId, + Number(effectiveVerses[0].chapterId), + Number(effectiveVerses[0].verseNumber), + effectiveVerses.length, + ); + }, [effectiveVerses, isLoggedIn, quranReaderStyles.quranFont, quranReaderStyles.mushafLines]); + if (!effectiveVerses || isValidating) { return ; } @@ -158,6 +179,7 @@ const PageContainer: React.FC = ({ pageNumber={Number(pageNumber)} quranReaderStyles={quranReaderStyles} pageIndex={pageIndex} + bookmarksRangeUrl={bookmarksRangeUrl} /> ); }; diff --git a/src/components/QuranReader/ReadingView/ShareQuranModal.tsx b/src/components/QuranReader/ReadingView/ShareQuranModal.tsx new file mode 100644 index 0000000000..7d9848e8f0 --- /dev/null +++ b/src/components/QuranReader/ReadingView/ShareQuranModal.tsx @@ -0,0 +1,75 @@ +/* eslint-disable i18next/no-literal-string */ +import React from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; + +// Import styles from the original component +import styles from '@/components/HomePage/ReadingSection/NewCard/ShareQuranModal.module.scss'; +import { ModalSize } from '@/dls/Modal/Content'; +import Modal from '@/dls/Modal/Modal'; +import ShareButtons from '@/dls/ShareButtons'; +import CloseIcon from '@/icons/close.svg'; +import { getFirstTimeReadingGuideNavigationUrl } from '@/utils/navigation'; +import { getBasePath } from '@/utils/url'; +import { getVerseAndChapterNumbersFromKey } from '@/utils/verse'; + +interface Props { + isOpen: boolean; + onClose: () => void; + verse?: { + verseKey: string; + }; +} + +const ShareQuranModal: React.FC = ({ isOpen, onClose, verse }) => { + const { t } = useTranslation('common'); + const shareURL = verse + ? (() => { + const [chapterId, verseNumber] = getVerseAndChapterNumbersFromKey(verse.verseKey); + return `${getBasePath()}/${chapterId}/${verseNumber}`; + })() + : `${getBasePath()}${getFirstTimeReadingGuideNavigationUrl()}`; + + return ( + + + + +

{t('share-quran-title')}

+
+ +
+

{t('share-quran-description-line-1')}

+

+ {t('share-quran-description-line-2')} +

+

+ {t('share-quran-description-line-3')} +

+
+ + { + const [chapterId, verseNumber] = getVerseAndChapterNumbersFromKey(verse.verseKey); + return { + chapterId, + verseNumber: Number(verseNumber), + }; + })() + : undefined + } + /> +
+
+ ); +}; + +export default ShareQuranModal; diff --git a/src/components/QuranReader/ReadingView/TranslationsButton/index.tsx b/src/components/QuranReader/ReadingView/TranslationsButton/index.tsx index d2a336a88a..09ab7c656f 100644 --- a/src/components/QuranReader/ReadingView/TranslationsButton/index.tsx +++ b/src/components/QuranReader/ReadingView/TranslationsButton/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import dynamic from 'next/dynamic'; @@ -12,32 +12,36 @@ import TranslationsView from '@/components/QuranReader/ReadingView/TranslationsV import TranslationViewCellSkeleton from '@/components/QuranReader/TranslationView/TranslationViewCellSkeleton'; import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; import ContentModalHandles from '@/dls/ContentModal/types/ContentModalHandles'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; import TranslationsIcon from '@/icons/translation.svg'; import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; import { selectSelectedTranslations } from '@/redux/slices/QuranReader/translations'; +import ZIndexVariant from '@/types/enums/ZIndexVariant'; +import { WordVerse } from '@/types/Word'; import { getDefaultWordFields, getMushafId } from '@/utils/api'; import { makeByVerseKeyUrl } from '@/utils/apiPaths'; import { logButtonClick, logEvent } from '@/utils/eventLogger'; import { VerseResponse } from 'types/ApiResponses'; -import Verse from 'types/Verse'; const ContentModal = dynamic(() => import('@/dls/ContentModal/ContentModal'), { ssr: false, }); interface Props { - verse: Verse; - onActionTriggered: () => void; + verse: WordVerse; + onActionTriggered?: () => void; + isTranslationView: boolean; } const CLOSE_POPOVER_AFTER_MS = 200; -const TranslationsButton: React.FC = ({ verse, onActionTriggered }) => { +const TranslationsButton: React.FC = ({ verse, onActionTriggered, isTranslationView }) => { const [isContentModalOpen, setIsContentModalOpen] = useState(false); const { t } = useTranslation('common'); const selectedTranslations = useSelector(selectSelectedTranslations); const quranReaderStyles = useSelector(selectQuranReaderStyles); const contentModalRef = useRef(); + const closeTimeoutRef = useRef>(); const translationsQueryKey = makeByVerseKeyUrl(`${verse.chapterId}:${verse.verseNumber}`, { words: true, translationFields: 'resource_name,language_id', @@ -57,24 +61,33 @@ const TranslationsButton: React.FC = ({ verse, onActionTriggered }) => { const onButtonClicked = () => { logButtonClick( - // eslint-disable-next-line i18next/no-literal-string - `reading_view_translations_modal_open`, + `${isTranslationView ? 'translation_view' : 'reading_view'}_translations_modal_open`, ); setIsContentModalOpen(true); }; const onModalClosed = () => { - // eslint-disable-next-line i18next/no-literal-string - logEvent(`reading_view_translations_modal_close`); + logEvent(`${isTranslationView ? 'translation_view' : 'reading_view'}_translations_modal_close`); setIsContentModalOpen(false); - setTimeout(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + closeTimeoutRef.current = setTimeout(() => { // we set a really short timeout to close the popover after the modal has been closed to allow enough time for the fadeout css effect to apply. - onActionTriggered(); + onActionTriggered?.(); }, CLOSE_POPOVER_AFTER_MS); }; const loading = useCallback(() => , []); + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + return ( <> = ({ verse, onActionTriggered }) => { hasCloseButton onClose={onModalClosed} onEscapeKeyDown={onModalClosed} + zIndexVariant={ZIndexVariant.MODAL} + isBottomSheetOnMobile > void; + onMenuChange: (menu: VerseActionsMenuType) => void; + openShareModal?: () => void; + bookmarksRangeUrl?: string | null; +} + +const MainActionsMenu: React.FC = ({ + word, + onActionTriggered, + onMenuChange, + openShareModal, + bookmarksRangeUrl, +}) => { + return ( + <> + {word?.verse?.timestamps && ( + + )} + + + + + + + + + + {/* Submenu navigation items */} + + + + ); +}; + +export default MainActionsMenu; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/BackMenuItem.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/BackMenuItem.tsx new file mode 100644 index 0000000000..41b0523e15 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/BackMenuItem.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import VerseActionsMenuType from '../types'; + +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import ChevronLeftIcon from '@/icons/chevron-left.svg'; +import { logButtonClick } from '@/utils/eventLogger'; + +interface Props { + onMenuChange: (menu: VerseActionsMenuType) => void; + targetMenu?: VerseActionsMenuType; + label?: string; + logAction?: string; +} + +/** + * A reusable back menu item component that can navigate to any menu type + * and display a custom label with configurable logging action + * @returns {JSX.Element} A PopoverMenu.Item component that navigates back to the specified menu + */ +const BackMenuItem: React.FC = ({ + onMenuChange, + targetMenu = VerseActionsMenuType.Main, + label, + logAction = 'back_verse_actions_menu', +}) => { + const { t } = useTranslation('common'); + const displayLabel = label || t('back'); + + const onBackClicked = () => { + logButtonClick(logAction); + onMenuChange(targetMenu); + }; + + return ( + } onClick={onBackClicked} shouldFlipOnRTL> + {displayLabel} + + ); +}; + +export default BackMenuItem; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/CopyMenuItem.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/CopyMenuItem.tsx new file mode 100644 index 0000000000..71f2fc5e48 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/CopyMenuItem.tsx @@ -0,0 +1,102 @@ +import React, { useContext, useEffect, useState } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { useSelector } from 'react-redux'; + +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import { ToastStatus, useToast } from '@/components/dls/Toast/Toast'; +import copyVerse from '@/components/Verse/AdvancedCopy/utils/copyVerse'; +import DataContext from '@/contexts/DataContext'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import CopyIcon from '@/icons/copy.svg'; +import { selectSelectedTranslations } from '@/redux/slices/QuranReader/translations'; +import Language from '@/types/Language'; +import { QuranFont } from '@/types/QuranReader'; +import { WordVerse } from '@/types/Word'; +import { areArraysEqual } from '@/utils/array'; +import { logButtonClick } from '@/utils/eventLogger'; + +interface Props { + verse: WordVerse; + onActionTriggered?: () => void; +} + +const RESET_ACTION_TEXT_TIMEOUT_MS = 3 * 1000; + +const CopyMenuItem: React.FC = ({ verse, onActionTriggered }) => { + const { t, lang } = useTranslation('common'); + const [isCopied, setIsCopied] = useState(false); + const chaptersData = useContext(DataContext); + const selectedTranslations = useSelector(selectSelectedTranslations, areArraysEqual) as number[]; + const toast = useToast(); + const { verseKey } = verse; + + const getTranslationObjects = () => { + const translations = {}; + selectedTranslations.forEach((translationId) => { + translations[translationId] = { + shouldBeCopied: true, + name: '', + }; + }); + return translations; + }; + + useEffect(() => { + let timeoutId: ReturnType; + if (isCopied) { + timeoutId = setTimeout(() => { + setIsCopied(false); + onActionTriggered?.(); + }, RESET_ACTION_TEXT_TIMEOUT_MS); + } + return () => { + clearTimeout(timeoutId); + }; + }, [isCopied, onActionTriggered]); + + const onCopyTextClicked = () => { + logButtonClick('reading_view_verse_actions_menu_copy'); + performCopy(); + }; + + const performCopy = () => { + copyVerse({ + showRangeOfVerses: false, + rangeEndVerse: null, + rangeStartVerse: null, + shouldCopyFootnotes: false, + shouldIncludeTranslatorName: true, + shouldCopyFont: QuranFont.Uthmani, + translations: getTranslationObjects(), + verseKey, + lang: lang as Language, + chaptersData, + }) + .then(() => { + setIsCopied(true); + toast(t('verse-copied'), { status: ToastStatus.Success }); + }) + .catch(() => { + setIsCopied(false); + }); + }; + + return ( + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + } + onClick={onCopyTextClicked} + > + {isCopied ? t('copied') : t('quran-reader:copy-verse')} + + ); +}; + +export default CopyMenuItem; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/MoreMenuItem.module.scss b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/MoreMenuItem.module.scss new file mode 100644 index 0000000000..4a2012e882 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/MoreMenuItem.module.scss @@ -0,0 +1,18 @@ +.menuWithChevron { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + svg { + margin-left: 8px; + } +} + +[dir="rtl"] .menuWithChevron { + svg { + margin-left: 0; + margin-right: 8px; + transform: rotate(180deg); + } +} diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/MoreMenuItem.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/MoreMenuItem.tsx new file mode 100644 index 0000000000..e61e6fecb0 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/MoreMenuItem.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import VerseActionsMenuType from '../types'; + +import styles from './MoreMenuItem.module.scss'; + +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import ChevronRightIcon from '@/icons/chevron-right.svg'; +import OverflowMenuIcon from '@/icons/menu_more_horiz.svg'; +import { logButtonClick } from '@/utils/eventLogger'; + +interface Props { + onMenuChange: (menu: VerseActionsMenuType) => void; +} + +const MoreMenuItem: React.FC = ({ onMenuChange }) => { + const { t } = useTranslation('common'); + + const onMoreClicked = () => { + logButtonClick('reading_view_verse_actions_menu_more'); + onMenuChange(VerseActionsMenuType.More); + }; + + return ( + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + } + onClick={onMoreClicked} + > +
+ {t('more')} + +
+
+ ); +}; + +export default MoreMenuItem; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/PlayAudioMenuItem.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/PlayAudioMenuItem.tsx new file mode 100644 index 0000000000..f718bcd5b5 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/PlayAudioMenuItem.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useContext } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import PlayIcon from '@/icons/play-outline.svg'; +import { logButtonClick } from '@/utils/eventLogger'; +import { getChapterNumberFromKey, getVerseNumberFromKey } from '@/utils/verse'; +import { selectIsVerseLoading } from '@/xstate/actors/audioPlayer/selectors'; +import { AudioPlayerMachineContext } from '@/xstate/AudioPlayerMachineContext'; + +interface PlayAudioMenuItemProps { + verse: { + verseKey: string; + timestamps?: any; + chapterId?: string | number; + verseNumber?: string | number; + }; + onActionTriggered?: () => void; +} + +const PlayAudioMenuItem: React.FC = ({ verse, onActionTriggered }) => { + const { verseKey } = verse; + const audioService = useContext(AudioPlayerMachineContext); + const { t } = useTranslation('common'); + + // Extract chapter and verse numbers either from props or from the verseKey + const chapterId = verse.chapterId || getChapterNumberFromKey(verseKey); + const verseNumber = verse.verseNumber || getVerseNumberFromKey(verseKey); + + // Check if verse is currently loading + const isVerseLoading = selectIsVerseLoading(audioService.state, verseKey); + + const onPlayClicked = useCallback(() => { + logButtonClick('reading_view_play_verse'); + + audioService.send({ + type: 'PLAY_AYAH', + surah: Number(chapterId), + ayahNumber: Number(verseNumber), + }); + + onActionTriggered?.(); + }, [audioService, chapterId, onActionTriggered, verseNumber]); + + return ( + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + } + onClick={onPlayClicked} + isDisabled={isVerseLoading} + > + {t('audio.player.play')} + + ); +}; + +export default PlayAudioMenuItem; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/QuestionsMenuItem.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/QuestionsMenuItem.tsx new file mode 100644 index 0000000000..7b6e8f2c7a --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/QuestionsMenuItem.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; + +import { useRouter } from 'next/router'; +import useTranslation from 'next-translate/useTranslation'; + +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import QuestionsModal from '@/components/QuestionAndAnswer/QuestionsModal'; +import { usePageQuestions } from '@/components/QuranReader/ReadingView/context/PageQuestionsContext'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import LightbulbOnIcon from '@/icons/lightbulb-on.svg'; +import LightbulbIcon from '@/icons/lightbulb.svg'; +import QuestionType from '@/types/QuestionsAndAnswers/QuestionType'; +import { WordVerse } from '@/types/Word'; +import { logButtonClick, logEvent } from '@/utils/eventLogger'; +import { fakeNavigate, getVerseAnswersNavigationUrl } from '@/utils/navigation'; + +interface Props { + verse: WordVerse; + onActionTriggered?: () => void; +} + +const QuestionsMenuItem: React.FC = ({ verse, onActionTriggered }) => { + const questionsData = usePageQuestions(); + const { t, lang } = useTranslation('common'); + const router = useRouter(); + const [isContentModalOpen, setIsContentModalOpen] = useState(false); + const { verseKey } = verse; + const hasQuestions = !!questionsData && questionsData[verseKey]?.total > 0; + const isClarificationQuestion = !!questionsData?.[verseKey]?.types?.[QuestionType.CLARIFICATION]; + + const onMenuItemClicked = () => { + logButtonClick('reading_view_verse_actions_menu_questions'); + setIsContentModalOpen(true); + fakeNavigate(getVerseAnswersNavigationUrl(verseKey), lang); + }; + + const onModalClose = () => { + logEvent('reading_view_questions_modal_close'); + setIsContentModalOpen(false); + fakeNavigate(router.asPath, router.locale); + onActionTriggered?.(); + }; + + const onModalClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + if (hasQuestions) { + return ( + <> + + ) : ( + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + ) + } + onClick={onMenuItemClicked} + > + {t('answers')} + + + {isContentModalOpen && ( + + )} + + ); + } + + return null; +}; + +export default QuestionsMenuItem; diff --git a/src/components/QuranReader/QuranReflectButton.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/QuranReflectMenuItem.tsx similarity index 53% rename from src/components/QuranReader/QuranReflectButton.tsx rename to src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/QuranReflectMenuItem.tsx index 820258546e..83f8cfc2fd 100644 --- a/src/components/QuranReader/QuranReflectButton.tsx +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/QuranReflectMenuItem.tsx @@ -1,36 +1,31 @@ -import { useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; -import classNames from 'classnames'; import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; import ContentModal from '@/components/dls/ContentModal/ContentModal'; +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; import ReflectionBodyContainer from '@/components/QuranReader/ReflectionView/ReflectionBodyContainer'; -import styles from '@/components/QuranReader/TranslationView/TranslationViewCell.module.scss'; -import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; import ChatIcon from '@/icons/chat.svg'; +import { WordVerse } from '@/types/Word'; import { logButtonClick } from '@/utils/eventLogger'; import { fakeNavigate, getVerseReflectionNavigationUrl } from '@/utils/navigation'; import { getVerseAndChapterNumbersFromKey } from '@/utils/verse'; -type QuranReflectButtonProps = { - verseKey: string; - isTranslationView?: boolean; +interface Props { + verse: WordVerse; onActionTriggered?: () => void; -}; +} -const QuranReflectButton = ({ - verseKey, - isTranslationView = true, - onActionTriggered, -}: QuranReflectButtonProps) => { +const QuranReflectMenuItem: React.FC = ({ verse, onActionTriggered }) => { const { t, lang } = useTranslation('common'); const router = useRouter(); const [isContentModalOpen, setIsContentModalOpen] = useState(false); + const { verseKey } = verse; - const onButtonClicked = () => { - // eslint-disable-next-line i18next/no-literal-string - logButtonClick(`${isTranslationView ? 'translation_view' : 'reading_view'}_reflect`); + const onMenuItemClicked = () => { + logButtonClick('reading_view_reflect'); setIsContentModalOpen(true); fakeNavigate(getVerseReflectionNavigationUrl(verseKey), lang); }; @@ -40,41 +35,32 @@ const QuranReflectButton = ({ const onModalClose = () => { setIsContentModalOpen(false); fakeNavigate(router.asPath, lang); - if (onActionTriggered) { - onActionTriggered(); - } + onActionTriggered?.(); }; const [initialChapterId, verseNumber] = getVerseAndChapterNumbersFromKey(verseKey); return ( <> - + {t('reflections-and-lessons')} + { - contentModalRef.current.scrollToTop(); + contentModalRef.current?.scrollToTop(); }} render={({ surahAndAyahSelection, body }) => ( void; + openShareModal?: () => void; +} + +const ShareMenuItem: React.FC = ({ onActionTriggered, openShareModal }) => { + const { t } = useTranslation('common'); + + const onShareClicked = () => { + logButtonClick('reading_view_verse_actions_menu_share'); + openShareModal?.(); + onActionTriggered?.(); + }; + + return ( + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + } + onClick={onShareClicked} + > + {t('share')} + + ); +}; + +export default ShareMenuItem; diff --git a/src/components/QuranReader/TafsirButton.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/TafsirMenuItem.tsx similarity index 55% rename from src/components/QuranReader/TafsirButton.tsx rename to src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/TafsirMenuItem.tsx index 2fd225e035..cb59d5d16b 100644 --- a/src/components/QuranReader/TafsirButton.tsx +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/TafsirMenuItem.tsx @@ -1,41 +1,35 @@ -import { useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; -import classNames from 'classnames'; import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; import { useSelector } from 'react-redux'; import ContentModal from '@/components/dls/ContentModal/ContentModal'; +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; import TafsirBody from '@/components/QuranReader/TafsirView/TafsirBody'; -import styles from '@/components/QuranReader/TranslationView/TranslationViewCell.module.scss'; -import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; -import TafsirIcon from '@/icons/book-open.svg'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import BookOpenIcon from '@/icons/book-open.svg'; import { selectSelectedTafsirs } from '@/redux/slices/QuranReader/tafsirs'; +import { WordVerse } from '@/types/Word'; import { logButtonClick, logEvent } from '@/utils/eventLogger'; import { fakeNavigate, getVerseSelectedTafsirNavigationUrl } from '@/utils/navigation'; import { getVerseAndChapterNumbersFromKey } from '@/utils/verse'; -type Props = { - verseKey: string; - isTranslationView?: boolean; +interface Props { + verse: WordVerse; onActionTriggered?: () => void; -}; +} -const TafsirButton: React.FC = ({ - verseKey, - isTranslationView = true, - onActionTriggered, -}) => { +const TafsirMenuItem: React.FC = ({ verse, onActionTriggered }) => { const { t, lang } = useTranslation('common'); const router = useRouter(); const tafsirs = useSelector(selectSelectedTafsirs); const [isContentModalOpen, setIsContentModalOpen] = useState(false); + const { verseKey } = verse; const [chapterId, verseNumber] = getVerseAndChapterNumbersFromKey(verseKey); - const onButtonClicked = () => { - logButtonClick( - `${isTranslationView ? 'translation_view' : 'reading_view'}_verse_actions_menu_tafsir`, - ); + const onMenuItemClicked = () => { + logButtonClick('reading_view_verse_actions_menu_tafsir'); setIsContentModalOpen(true); fakeNavigate( getVerseSelectedTafsirNavigationUrl(chapterId, Number(verseNumber), tafsirs[0]), @@ -46,47 +40,33 @@ const TafsirButton: React.FC = ({ const contentModalRef = useRef(null); const onModalClose = () => { - if (isTranslationView) { - logEvent('translation_view_tafsir_modal_close'); - } else { - logEvent('reading_view_tafsir_modal_close'); - } + logEvent('reading_view_tafsir_modal_close'); setIsContentModalOpen(false); fakeNavigate(router.asPath, router.locale); - if (onActionTriggered) { - onActionTriggered(); - } + onActionTriggered?.(); }; return ( <> - + {t('quran-reader:tafsirs')} + { - contentModalRef.current.scrollToTop(); + contentModalRef.current?.scrollToTop(); }} render={({ body, languageAndTafsirSelection, surahAndAyahSelection }) => { return ( @@ -108,4 +88,4 @@ const TafsirButton: React.FC = ({ ); }; -export default TafsirButton; +export default TafsirMenuItem; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/TranslationsMenuItem.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/TranslationsMenuItem.tsx new file mode 100644 index 0000000000..2fdeed13ba --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MenuItems/TranslationsMenuItem.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useRef, useState } from 'react'; + +import dynamic from 'next/dynamic'; +import useTranslation from 'next-translate/useTranslation'; +import { useSelector } from 'react-redux'; + +import DataFetcher from '@/components/DataFetcher'; +import ContentModalHandles from '@/components/dls/ContentModal/types/ContentModalHandles'; +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import TranslationsView from '@/components/QuranReader/ReadingView/TranslationsView'; +import TranslationViewCellSkeleton from '@/components/QuranReader/TranslationView/TranslationViewCellSkeleton'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import useSafeTimeout from '@/hooks/useSafeTimeout'; +import TranslationsIcon from '@/icons/translation.svg'; +import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; +import { selectSelectedTranslations } from '@/redux/slices/QuranReader/translations'; +import { WordVerse } from '@/types/Word'; +import { getDefaultWordFields, getMushafId } from '@/utils/api'; +import { makeByVerseKeyUrl } from '@/utils/apiPaths'; +import { logButtonClick, logEvent } from '@/utils/eventLogger'; +import { VerseResponse } from 'types/ApiResponses'; + +const ContentModal = dynamic(() => import('@/components/dls/ContentModal/ContentModal'), { + ssr: false, +}); + +interface Props { + verse: WordVerse; + onActionTriggered?: () => void; +} + +const CLOSE_POPOVER_AFTER_MS = 200; + +const TranslationsMenuItem: React.FC = ({ verse, onActionTriggered }) => { + const [isContentModalOpen, setIsContentModalOpen] = useState(false); + const { t } = useTranslation('common'); + const selectedTranslations = useSelector(selectSelectedTranslations); + const quranReaderStyles = useSelector(selectQuranReaderStyles); + const contentModalRef = useRef(); + const translationsQueryKey = makeByVerseKeyUrl(`${verse.chapterId}:${verse.verseNumber}`, { + words: true, + translationFields: 'resource_name,language_id', + translations: selectedTranslations.join(','), + ...getDefaultWordFields(quranReaderStyles.quranFont), + ...getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines), + }); + + const renderTranslationsView = useCallback( + (data: VerseResponse) => { + if (!data) return ; + const { verse: responseVerse } = data; + return ; + }, + [quranReaderStyles], + ); + + const onMenuItemClicked = () => { + logButtonClick('reading_view_translations'); + setIsContentModalOpen(true); + }; + + // Use the safe timeout hook + const setSafeTimeout = useSafeTimeout(); + + const onModalClosed = () => { + logEvent('reading_view_translations_modal_close'); + setIsContentModalOpen(false); + + // Use the safe timeout hook to handle cleanup automatically + setSafeTimeout(() => { + // we set a really short timeout to close the popover after the modal has been closed to allow enough time for the fadeout css effect to apply. + onActionTriggered?.(); + }, CLOSE_POPOVER_AFTER_MS); + }; + + const loading = useCallback(() => , []); + + return ( + <> + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + } + onClick={onMenuItemClicked} + > + {t('translations')} + + {t('translations')}

} + hasCloseButton + onClose={onModalClosed} + onEscapeKeyDown={onModalClosed} + > + +
+ + ); +}; + +export default TranslationsMenuItem; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/MoreActionsMenu.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/MoreActionsMenu.tsx new file mode 100644 index 0000000000..77211ac2cc --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/MoreActionsMenu.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import BackMenuItem from './MenuItems/BackMenuItem'; +import VerseActionsMenuType from './types'; + +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import WordByWordVerseAction from '@/components/QuranReader/ReadingView/WordByWordVerseAction'; +import SaveToCollectionAction from '@/components/Verse/SaveToCollectionAction'; +import TranslationFeedbackAction from '@/components/Verse/TranslationFeedback/TranslationFeedbackAction'; +import VerseActionAdvancedCopy from '@/components/Verse/VerseActionAdvancedCopy'; +import VerseActionRepeatAudio from '@/components/Verse/VerseActionRepeatAudio'; +import { WordVerse } from '@/types/Word'; +import { isLoggedIn } from '@/utils/auth/login'; + +interface Props { + verse: WordVerse; + onActionTriggered?: () => void; + onMenuChange: (menu: VerseActionsMenuType) => void; +} + +const MoreActionsMenu: React.FC = ({ verse, onActionTriggered, onMenuChange }) => { + const { t } = useTranslation('common'); + + return ( + <> + + + + {isLoggedIn() && } + + + + + + + ); +}; + +export default MoreActionsMenu; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/index.tsx b/src/components/QuranReader/ReadingView/WordActionsMenu/index.tsx index d926a8e157..65d17cdf70 100644 --- a/src/components/QuranReader/ReadingView/WordActionsMenu/index.tsx +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/index.tsx @@ -1,55 +1,60 @@ -import React from 'react'; - -import QuranReflectButton from '../../QuranReflectButton'; -import CopyButton from '../CopyButton'; +import React, { useState } from 'react'; +import MainActionsMenu from './MainActionsMenu'; +import MoreActionsMenu from './MoreActionsMenu'; +import VerseActionsMenuType from './types'; import styles from './WordActionsMenu.module.scss'; -import QuestionsButton from '@/components/QuranReader/ReadingView/Buttons/QuestionsButton'; -import TranslationsButton from '@/components/QuranReader/ReadingView/TranslationsButton'; -import TafsirButton from '@/components/QuranReader/TafsirButton'; -import OverflowVerseActionsMenu from '@/components/Verse/OverflowVerseActionsMenu'; -import PlayVerseAudioButton from '@/components/Verse/PlayVerseAudioButton'; +import { logEvent } from '@/utils/eventLogger'; import Word from 'types/Word'; type Props = { word: Word; onActionTriggered?: () => void; + openShareModal?: () => void; + bookmarksRangeUrl?: string | null; }; -const ReadingViewWordActionsMenu: React.FC = ({ word, onActionTriggered }) => { - return ( -
- - - - - - {word?.verse?.timestamps && ( - - )} - -
- -
-
- ); +const ReadingViewWordActionsMenu: React.FC = ({ + word, + onActionTriggered, + openShareModal, + bookmarksRangeUrl, +}) => { + const [selectedMenu, setSelectedMenu] = useState(VerseActionsMenuType.Main); + + const onMenuChange = (menu: VerseActionsMenuType) => { + logEvent(`reading_view_verse_actions_menu_${menu}`); + setSelectedMenu(menu); + }; + + // Render the appropriate menu based on the selected state + const renderMenu = () => { + switch (selectedMenu) { + case VerseActionsMenuType.Main: + return ( + + ); + case VerseActionsMenuType.More: + return ( + + ); + default: + return null; + } + }; + + return
{renderMenu()}
; }; export default ReadingViewWordActionsMenu; diff --git a/src/components/QuranReader/ReadingView/WordActionsMenu/types.ts b/src/components/QuranReader/ReadingView/WordActionsMenu/types.ts new file mode 100644 index 0000000000..0ddb88a949 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordActionsMenu/types.ts @@ -0,0 +1,9 @@ +/** + * Shared enum for all verse action menu types across the application + */ +enum VerseActionsMenuType { + Main = 'main', + More = 'more', +} + +export default VerseActionsMenuType; diff --git a/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordSkeleton/WordByWordSkeleton.module.scss b/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordSkeleton/WordByWordSkeleton.module.scss new file mode 100644 index 0000000000..312a535db7 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordSkeleton/WordByWordSkeleton.module.scss @@ -0,0 +1,46 @@ +@use "src/styles/breakpoints"; +@use "src/styles/utility"; +@use "src/styles/theme"; + +.container { + width: 100%; + padding: var(--spacing-medium); + background-color: var(--color-background-default); +} + +.headingContainer { + width: 60%; + height: var(--spacing-mega); + margin-bottom: var(--spacing-large); +} + +.wordGroup { + display: flex; + justify-content: center; + flex-wrap: wrap; + margin-bottom: var(--spacing-large); +} + +.wordItem { + display: flex; + flex-direction: column; + align-items: center; + margin: 0 var(--spacing-medium) var(--spacing-medium); + width: calc(var(--spacing-xlarge-px) * 2); +} + +.arabicWord { + height: calc(var(--spacing-medium2) * 2); + width: calc(var(--spacing-large-px) * 2); + margin-bottom: var(--spacing-xsmall); +} + +.translation { + height: var(--spacing-medium2); + width: calc(var(--spacing-xlarge-px) * 2); +} + +.separator { + margin: var(--spacing-large) 0; + width: 100%; +} diff --git a/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordSkeleton/index.tsx b/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordSkeleton/index.tsx new file mode 100644 index 0000000000..7824dfef5c --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordSkeleton/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import range from 'lodash/range'; + +import styles from './WordByWordSkeleton.module.scss'; + +import Separator from '@/components/dls/Separator/Separator'; +import Skeleton from '@/components/dls/Skeleton/Skeleton'; + +const WordByWordSkeleton: React.FC = () => { + // Number of words to show in each section + const wordsCount = 9; // Showing 9 words like in the example + + // Create a word item with Arabic text and translation/transliteration + const renderWordItem = (key: string) => ( +
+ + +
+ ); + + return ( +
+ {/* Translation Section */} +
+ +
+
+ {range(0, wordsCount).map((index) => renderWordItem(`translation-${index}`))} +
+ + + + {/* Transliteration Section */} +
+ +
+
+ {range(0, wordsCount).map((index) => renderWordItem(`transliteration-${index}`))} +
+
+ ); +}; + +export default WordByWordSkeleton; diff --git a/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordVerseAction.module.scss b/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordVerseAction.module.scss index 9f29357da3..c896c6643c 100644 --- a/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordVerseAction.module.scss +++ b/src/components/QuranReader/ReadingView/WordByWordVerseAction/WordByWordVerseAction.module.scss @@ -5,3 +5,9 @@ .separator { margin-block: var(--spacing-large); } + +.fallbackMessage { + text-align: center; + margin-bottom: var(--spacing-medium); + color: var(--color-text-default); +} diff --git a/src/components/QuranReader/ReadingView/WordByWordVerseAction/index.tsx b/src/components/QuranReader/ReadingView/WordByWordVerseAction/index.tsx index 29e52e473b..1786de3594 100644 --- a/src/components/QuranReader/ReadingView/WordByWordVerseAction/index.tsx +++ b/src/components/QuranReader/ReadingView/WordByWordVerseAction/index.tsx @@ -1,41 +1,67 @@ -import { useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import useTranslation from 'next-translate/useTranslation'; +import { shallowEqual, useSelector } from 'react-redux'; import WordByWordHeading from './WordByWordHeading'; +import WordByWordSkeleton from './WordByWordSkeleton'; import styles from './WordByWordVerseAction.module.scss'; +import { fetcher } from '@/api'; +import DataFetcher from '@/components/DataFetcher'; import PlainVerseText from '@/components/Verse/PlainVerseText'; import ContentModalHandles from '@/dls/ContentModal/types/ContentModalHandles'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; import PopoverMenu from '@/dls/PopoverMenu/PopoverMenu'; import Separator from '@/dls/Separator/Separator'; import SearchIcon from '@/icons/search-book.svg'; +import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; +import { selectSelectedTranslations } from '@/redux/slices/QuranReader/translations'; +import { VersesResponse } from '@/types/ApiResponses'; +import { WordVerse } from '@/types/Word'; +import { getMushafId, getDefaultWordFields } from '@/utils/api'; +import { makeVersesUrl } from '@/utils/apiPaths'; +import { areArraysEqual } from '@/utils/array'; import { logButtonClick, logEvent } from '@/utils/eventLogger'; -import Verse from 'types/Verse'; +import { getVerseWords } from '@/utils/verse'; const ContentModal = dynamic(() => import('@/dls/ContentModal/ContentModal'), { ssr: false, }); type Props = { - verse: Verse; + verse: WordVerse; onActionTriggered?: () => void; + isTranslationView?: boolean; }; const CLOSE_POPOVER_AFTER_MS = 150; -const WordByWordVerseAction: React.FC = ({ verse, onActionTriggered }) => { +const WordByWordVerseAction: React.FC = ({ + verse, + onActionTriggered, + isTranslationView, +}) => { const [isContentModalOpen, setIsContentModalOpen] = useState(false); - const { t } = useTranslation('common'); + const { t, lang } = useTranslation('common'); const contentModalRef = useRef(); + const closeTimeoutRef = useRef>(); + const quranReaderStyles = useSelector(selectQuranReaderStyles, shallowEqual); + const { quranFont, mushafLines } = quranReaderStyles; + const { mushaf } = getMushafId(quranFont, mushafLines); + const selectedTranslations = useSelector(selectSelectedTranslations, areArraysEqual); const onModalClosed = () => { - // eslint-disable-next-line i18next/no-literal-string - logEvent(`reading_view_wbw_modal_close`); + logEvent( + `${isTranslationView ? 'translation_view' : 'reading_view'}_reading_view_wbw_modal_close`, + ); setIsContentModalOpen(false); if (onActionTriggered) { - setTimeout(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + closeTimeoutRef.current = setTimeout(() => { // we set a really short timeout to close the popover after the modal has been closed to allow enough time for the fadeout css effect to apply. onActionTriggered(); }, CLOSE_POPOVER_AFTER_MS); @@ -43,14 +69,53 @@ const WordByWordVerseAction: React.FC = ({ verse, onActionTriggered }) => }; const onIconClicked = () => { - // eslint-disable-next-line i18next/no-literal-string - logButtonClick(`reading_view_verse_actions_menu_wbw`); + logButtonClick( + `${ + isTranslationView ? 'translation_view' : 'reading_view' + }_reading_view_verse_actions_menu_wbw`, + ); setIsContentModalOpen(true); }; + // Extract chapter ID and verse number from the verse prop + const chapterId = + typeof verse.chapterId === 'string' ? verse.chapterId : verse.chapterId.toString(); + const { verseNumber } = verse; + + // Loading component for DataFetcher + const loading = useCallback(() => , []); + + // API parameters for fetching verse data + const apiParams = { + words: true, + perPage: 1, + translations: selectedTranslations.join(','), + page: verseNumber, + ...getDefaultWordFields(quranReaderStyles.quranFont), + mushaf, + }; + + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + return ( <> - } onClick={onIconClicked}> + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + } + onClick={onIconClicked} + > {t('wbw')} = ({ verse, onActionTriggered }) => onClose={onModalClosed} onEscapeKeyDown={onModalClosed} > - - - - - + {isContentModalOpen && ( + fetcher(makeVersesUrl(chapterId, lang, apiParams))} + render={(data: VersesResponse) => { + if (!data || !data.verses || data.verses.length === 0) { + return

{t('no-verses-available')}

; + } + + const words = data.verses.map((verseItem) => getVerseWords(verseItem)).flat(); + + return ( + <> + + + + + + + ); + }} + /> + )}
); diff --git a/src/components/QuranReader/ReadingView/WordMobileModal/WordMobileModal.module.scss b/src/components/QuranReader/ReadingView/WordMobileModal/WordMobileModal.module.scss new file mode 100644 index 0000000000..02285ccffa --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordMobileModal/WordMobileModal.module.scss @@ -0,0 +1,32 @@ +// This is less than the modal and the popover in (OverflowVerseActionsMenu) +$word-modal-z-index: var(--z-index-smaller-modal); +$word-modal-overlay-z-index: var(--z-index-sticky); + +.container { + width: 100%; + display: flex; + flex-direction: column; +} + +.topActionsContainer { + display: flex; + flex-direction: column; + width: 100%; +} + +.separator { + height: 1px; + background-color: var(--color-borders-hairline); + margin-block: var(--spacing-medium); +} + +.bottomActionsContainer { + width: 100%; +} + +.contentClassName { + z-index: $word-modal-z-index; +} +.overlayClassName { + z-index: $word-modal-overlay-z-index; +} diff --git a/src/components/QuranReader/ReadingView/WordMobileModal/index.tsx b/src/components/QuranReader/ReadingView/WordMobileModal/index.tsx new file mode 100644 index 0000000000..47a1c3cf01 --- /dev/null +++ b/src/components/QuranReader/ReadingView/WordMobileModal/index.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { Separator } from '@radix-ui/react-separator'; + +import styles from './WordMobileModal.module.scss'; + +import Modal from '@/components/dls/Modal/Modal'; +import BottomActions from '@/components/QuranReader/TranslationView/BottomActions'; +import TopActions from '@/components/QuranReader/TranslationView/TopActions'; +import Word from 'types/Word'; + +interface Props { + isOpen: boolean; + onClose: () => void; + word: Word; + bookmarksRangeUrl?: string | null; +} + +/** + * Mobile modal bottom sheet for word actions in reading view + * Displays top actions, and bottom actions + * @returns {React.FC} React component for mobile word actions modal + */ +const WordMobileModal: React.FC = ({ isOpen, onClose, word, bookmarksRangeUrl }) => { + const { verse } = word; + if (!verse) return null; + + return ( + + +
+
+ +
+ + + +
+ +
+
+
+
+ ); +}; + +export default WordMobileModal; diff --git a/src/components/QuranReader/ReadingView/WordPopover/WordPopover.module.scss b/src/components/QuranReader/ReadingView/WordPopover/WordPopover.module.scss deleted file mode 100644 index 406663116a..0000000000 --- a/src/components/QuranReader/ReadingView/WordPopover/WordPopover.module.scss +++ /dev/null @@ -1,13 +0,0 @@ -@use "src/styles/breakpoints"; - -.content { - z-index: var(--z-index-default); -} - -.trigger { - @include breakpoints.smallerThanTablet { - // disable word selecting when on mobile so that we can show the popover on long tap. - -webkit-touch-callout: none; - user-select: none; - } -} diff --git a/src/components/QuranReader/ReadingView/WordPopover/index.tsx b/src/components/QuranReader/ReadingView/WordPopover/index.tsx index f05a261c88..65d30c699a 100644 --- a/src/components/QuranReader/ReadingView/WordPopover/index.tsx +++ b/src/components/QuranReader/ReadingView/WordPopover/index.tsx @@ -1,36 +1,51 @@ -import React, { useState, useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; +import dynamic from 'next/dynamic'; import { useDispatch } from 'react-redux'; -import ReadingViewWordActionsMenu from '../WordActionsMenu'; - -import styles from './WordPopover.module.scss'; - -import Popover, { ContentSide } from '@/dls/Popover'; +import PopoverMenu, { PopoverMenuExpandDirection } from '@/components/dls/PopoverMenu/PopoverMenu'; +import ReadingViewWordActionsMenu from '@/components/QuranReader/ReadingView/WordActionsMenu'; import { - setReadingViewSelectedVerseKey, setReadingViewHoveredVerseKey, + setReadingViewSelectedVerseKey, } from '@/redux/slices/QuranReader/readingViewVerse'; import { logEvent } from '@/utils/eventLogger'; import Word from 'types/Word'; +const ShareQuranModal = dynamic( + () => import('@/components/QuranReader/ReadingView/ShareQuranModal'), + { ssr: false }, +); + type Props = { word: Word; children: React.ReactNode; + onOpenChange: (isOpen: boolean) => void; + bookmarksRangeUrl?: string | null; }; -const ReadingViewWordPopover: React.FC = ({ word, children }) => { - const [isTooltipOpened, setIsTooltipOpened] = useState(false); +const ReadingViewWordPopover: React.FC = ({ + word, + children, + onOpenChange, + bookmarksRangeUrl, +}) => { + const [isMenuOpened, setIsMenuOpened] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const dispatch = useDispatch(); - const onOpenChange = useCallback( + const handleOpenChange = useCallback( (isOpen: boolean) => { - setIsTooltipOpened(isOpen); - // eslint-disable-next-line i18next/no-literal-string + setIsMenuOpened(isOpen); logEvent(`reading_view_overflow_menu_${isOpen ? 'open' : 'close'}`); dispatch(setReadingViewSelectedVerseKey(isOpen ? word.verseKey : null)); + + if (isOpen) { + onOpenChange(isOpen); + } }, - [dispatch, word.verseKey], + [dispatch, word.verseKey, onOpenChange], ); const onHoverChange = useCallback( @@ -40,8 +55,16 @@ const ReadingViewWordPopover: React.FC = ({ word, children }) => { [dispatch, word.verseKey], ); const onActionTriggered = useCallback(() => { - onOpenChange(false); - }, [onOpenChange]); + handleOpenChange(false); + }, [handleOpenChange]); + + const onCloseShareModal = useCallback(() => { + setIsShareModalOpen(false); + }, []); + + const openShareModal = useCallback(() => { + setIsShareModalOpen(true); + }, []); const onMouseEnter = useCallback(() => { onHoverChange(true); @@ -52,25 +75,26 @@ const ReadingViewWordPopover: React.FC = ({ word, children }) => { }, [onHoverChange]); return ( - - {children} -
- } - tip - isModal - open={isTooltipOpened} - onOpenChange={onOpenChange} - triggerStyles={styles.trigger} - contentStyles={styles.content} - defaultStyling={false} - stopPropagation - > - - + <> + + {children} +
+ } + isOpen={isMenuOpened} + onOpenChange={handleOpenChange} + expandDirection={PopoverMenuExpandDirection.BOTTOM} + > + + + + ); }; diff --git a/src/components/QuranReader/ReadingView/context/PageQuestionsContext.tsx b/src/components/QuranReader/ReadingView/context/PageQuestionsContext.tsx index e2d0412104..71aa69c20f 100644 --- a/src/components/QuranReader/ReadingView/context/PageQuestionsContext.tsx +++ b/src/components/QuranReader/ReadingView/context/PageQuestionsContext.tsx @@ -1,6 +1,10 @@ import { createContext, useContext } from 'react'; -export const PageQuestionsContext = createContext | undefined>(undefined); +import { QuestionsData } from '@/utils/auth/api'; + +export const PageQuestionsContext = createContext | undefined>( + undefined, +); export const usePageQuestions = () => { const context = useContext(PageQuestionsContext); diff --git a/src/components/QuranReader/ReadingView/groupLinesByVerses.ts b/src/components/QuranReader/ReadingView/groupLinesByVerses.ts index e76b609145..a75cd9d5ad 100644 --- a/src/components/QuranReader/ReadingView/groupLinesByVerses.ts +++ b/src/components/QuranReader/ReadingView/groupLinesByVerses.ts @@ -17,12 +17,7 @@ import Word from 'types/Word'; * @returns {Record => { - let words = []; - - // Flattens the verses into an array of words - verses.forEach((verse) => { - words = [...words, ...getVerseWords(verse, true)]; - }); + const words: Word[] = verses.flatMap((verse) => getVerseWords(verse)); // Groups the words based on their (page and) line number const lines = groupBy(words, (word) => `Page${word.pageNumber}-Line${word.lineNumber}`); diff --git a/src/components/QuranReader/ReadingView/utils/translation.ts b/src/components/QuranReader/ReadingView/utils/translation.ts new file mode 100644 index 0000000000..7df520be61 --- /dev/null +++ b/src/components/QuranReader/ReadingView/utils/translation.ts @@ -0,0 +1,30 @@ +import Translation from '@/types/Translation'; + +/** + * Get a display string for translation name based on available translations + * + * @param {Translation[] | undefined} translations - Array of translations or undefined + * @param {Function} t - Translation function from useTranslation + * @returns {string} Formatted translation name string + */ +const getTranslationsLabelString = (translations?: Translation[], t?: any): string => { + let translationName = t ? t('settings.no-translation-selected') : 'No translation selected'; + + if (translations?.length === 1) { + translationName = translations[0].resourceName; + } else if (translations?.length > 1) { + // Find the shortest translation name + let shortestTranslation = translations[0]; + for (let i = 1; i < translations.length; i += 1) { + if (translations[i].resourceName.length < shortestTranslation.resourceName.length) { + shortestTranslation = translations[i]; + } + } + + translationName = shortestTranslation.resourceName; + } + + return translationName; +}; + +export default getTranslationsLabelString; diff --git a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/ReflectionBody.module.scss b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/ReflectionBody.module.scss index 837d0bde27..7b16480d7d 100644 --- a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/ReflectionBody.module.scss +++ b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/ReflectionBody.module.scss @@ -1,3 +1,5 @@ +@use 'src/styles/breakpoints'; + .separatorContainer { margin-block: var(--spacing-large); } diff --git a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/index.tsx b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/index.tsx index d8ffe026b8..53eb6905cc 100644 --- a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/index.tsx +++ b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBody/index.tsx @@ -26,6 +26,7 @@ interface Props { scrollToTop: () => void; setSelectedVerseNumber: (verseNumber: string) => void; selectedContentType: ContentType; + isModal?: boolean; } const ReflectionBody: React.FC = ({ @@ -35,6 +36,7 @@ const ReflectionBody: React.FC = ({ scrollToTop, setSelectedVerseNumber, selectedContentType, + isModal = false, }) => { const { t, lang } = useTranslation('quran-reader'); const chaptersData = useContext(DataContext); @@ -83,14 +85,18 @@ const ReflectionBody: React.FC = ({ return (
- -
- -
+ {!isModal && ( + <> + +
+ +
+ + )} {data?.data?.length === 0 ? ( ) : ( diff --git a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBodyContainer.module.scss b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBodyContainer.module.scss index dad1b76f56..9b75fb19a8 100644 --- a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBodyContainer.module.scss +++ b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionBodyContainer.module.scss @@ -1,5 +1,55 @@ +@use 'src/styles/breakpoints'; + +.reflectionDataContainer { + padding-block-start: var(--spacing-small-px); + padding-inline: var(--spacing-medium-px); + @include breakpoints.tablet { + padding-block-start: var(--spacing-medium-px); + padding-inline: var(--spacing-large-px); + } +} + +.tabsContainerWrapper .tabsContainer { + border-block-end: unset; + + .tab { + padding-inline: var(--spacing-xxlarge-px); + padding-block: var(--spacing-medium-px); + inline-size: 100%; + font-weight: var(--font-weight-semibold); + color: var(--color-text-faded-new); + border-block-end: var(--spacing-micro-px) solid var(--color-borders-hairline); + + svg { + inline-size: var(--spacing-medium-plus-px); + block-size: var(--spacing-medium-plus-px); + @include breakpoints.tablet { + inline-size: var(--spacing-medium2-px); + block-size: var(--spacing-medium2-px); + } + } + } + + .tab + .tab { + margin-inline-start: unset; + } + + .tabActive { + color: var(--color-success-medium); + border-block-end-color: var(--color-success-medium); + } +} + .titleContainer { - display: flex; - align-items: center; - gap: var(--spacing-micro-px); + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-micro-px); +} + +.modalTitleContainer { + font-size: var(--font-size-xsmall-px); + @include breakpoints.tablet { + font-size: var(--font-size-normal-px); + } } diff --git a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/ReflectionSurahAndAyahSelection.module.scss b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/ReflectionSurahAndAyahSelection.module.scss index 0f033c4e64..b8598dbbf5 100644 --- a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/ReflectionSurahAndAyahSelection.module.scss +++ b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/ReflectionSurahAndAyahSelection.module.scss @@ -1,4 +1,4 @@ -@use "src/styles/breakpoints"; +@use 'src/styles/breakpoints'; .surahSelectionContainer { @include breakpoints.tablet { diff --git a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/index.tsx b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/index.tsx index f1a85d6440..24b189e59d 100644 --- a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/index.tsx +++ b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/ReflectionSurahAndAyahSelection/index.tsx @@ -2,8 +2,6 @@ import React from 'react'; import useTranslation from 'next-translate/useTranslation'; -import styles from './ReflectionSurahAndAyahSelection.module.scss'; - import SurahAndAyahSelection from '@/components/QuranReader/TafsirView/SurahAndAyahSelection'; import { logItemSelectionChange } from '@/utils/eventLogger'; import { fakeNavigate, getReflectionNavigationUrl } from '@/utils/navigation'; @@ -44,14 +42,12 @@ const ReflectionSurahAndAyahSelection: React.FC = ({ }; return ( -
- -
+ ); }; diff --git a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/helpers.tsx b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/helpers.tsx new file mode 100644 index 0000000000..f4abee06bb --- /dev/null +++ b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/helpers.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; + +import styles from './ReflectionBodyContainer.module.scss'; + +import { Tab } from '@/dls/Tabs/Tabs'; +import ChatIcon from '@/icons/chat.svg'; +import LearningPlanIcon from '@/icons/learning-plan.svg'; +import { logErrorToSentry } from '@/lib/sentry'; +import { isLoggedIn } from '@/utils/auth/login'; +import { logPostView } from '@/utils/quranReflect/apiPaths'; +import ContentType from 'types/QuranReflect/ContentType'; + +export const getReflectionTabs = (t: (key: string) => string, isModal: boolean): Tab[] => [ + { + title: ( +
+ + {t('common:reflections')} +
+ ), + value: ContentType.REFLECTIONS, + }, + { + title: ( +
+ + {t('common:lessons')} +
+ ), + value: ContentType.LESSONS, + }, +]; + +/** + * Handle when the reflection is viewed by logging the post view. + * The login status only affects the Sentry transaction name for error tracking. + */ +export const handleReflectionViewed = (reflectionContainer: Element) => { + const postId = reflectionContainer.getAttribute('data-post-id'); + + if (!postId) { + return; + } + + logPostView(postId).catch((e) => { + logErrorToSentry(e, { + transactionName: isLoggedIn() + ? 'post_reflection_views_logged_in' + : 'post_reflection_views_logged_out', + metadata: { + postId, + }, + }); + }); +}; diff --git a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/index.tsx b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/index.tsx index a95d44a4b9..ad6716ba12 100644 --- a/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/index.tsx +++ b/src/components/QuranReader/ReflectionView/ReflectionBodyContainer/index.tsx @@ -2,17 +2,17 @@ import { useCallback, useState } from 'react'; import dynamic from 'next/dynamic'; import useTranslation from 'next-translate/useTranslation'; +import { useSelector } from 'react-redux'; +import { getReflectionTabs, handleReflectionViewed } from './helpers'; import styles from './ReflectionBodyContainer.module.scss'; import DataFetcher from '@/components/DataFetcher'; import { REFLECTIONS_OBSERVER_ID } from '@/components/QuranReader/observer'; import TafsirSkeleton from '@/components/QuranReader/TafsirView/TafsirSkeleton'; -import NewLabel from '@/dls/Badge/NewLabel'; import Tabs from '@/dls/Tabs/Tabs'; import useGlobalIntersectionObserverWithDelay from '@/hooks/useGlobalIntersectionObserverWithDelay'; -import { logErrorToSentry } from '@/lib/sentry'; -import { isLoggedIn } from '@/utils/auth/login'; +import { selectAyahReflectionsLanguages } from '@/redux/slices/defaultSettings'; import { logEvent } from '@/utils/eventLogger'; import { fakeNavigate, @@ -20,11 +20,11 @@ import { getVerseReflectionNavigationUrl, } from '@/utils/navigation'; import { - makeAyahReflectionsUrl, - logPostView, - REFLECTION_POST_TYPE_ID, LESSON_POST_TYPE_ID, + REFLECTION_POST_TYPE_ID, + makeAyahReflectionsUrl, } from '@/utils/quranReflect/apiPaths'; +import { reflectionLanguagesToLocaleCodes } from '@/utils/quranReflect/locale'; import AyahReflectionsResponse from 'types/QuranReflect/AyahReflectionsResponse'; import ContentType from 'types/QuranReflect/ContentType'; @@ -42,6 +42,7 @@ type ReflectionBodyProps = { scrollToTop: () => void; render: (renderProps: { surahAndAyahSelection: JSX.Element; body: JSX.Element }) => JSX.Element; initialContentType?: ContentType; + isModal?: boolean; }; const ReflectionBodyContainer = ({ @@ -50,24 +51,14 @@ const ReflectionBodyContainer = ({ initialVerseNumber, scrollToTop, initialContentType = ContentType.REFLECTIONS, + isModal = false, }: ReflectionBodyProps) => { const [selectedChapterId, setSelectedChapterId] = useState(initialChapterId); const [selectedVerseNumber, setSelectedVerseNumber] = useState(initialVerseNumber); const [selectedContentType, setSelectedContentType] = useState(initialContentType); const { lang, t } = useTranslation(); - - const tabs = [ - { title: t('common:reflections'), value: ContentType.REFLECTIONS }, - { - title: ( -
- {t('common:lessons')} - -
- ), - value: ContentType.LESSONS, - }, - ]; + const reflectionLanguages = useSelector(selectAyahReflectionsLanguages); + const reflectionLanguageIsoCodes = reflectionLanguagesToLocaleCodes(reflectionLanguages); const handleTabChange = (value: ContentType) => { logEvent('reflection_view_tab_change', { tab: value }); @@ -80,28 +71,9 @@ const ReflectionBodyContainer = ({ fakeNavigate(newUrl, lang); }; - /** - * Handle when the reflection is viewed: - * - * 1. If the user is logged in, we will call QDC's backend API. - * 2. Otherwise, we will call QR's API directly. - */ - const onReflectionViewed = useCallback((reflectionContainer: Element) => { - const postId = reflectionContainer.getAttribute('data-post-id'); - logPostView(postId).catch((e) => { - logErrorToSentry(e, { - transactionName: isLoggedIn() - ? 'post_reflection_views_logged_in' - : 'post_reflection_views_logged_out', - metadata: { - postId, - }, - }); - }); - }, []); useGlobalIntersectionObserverWithDelay( { threshold: 1 }, - onReflectionViewed, + handleReflectionViewed, REFLECTIONS_OBSERVER_ID, 'postId', 'countAsViewedAfter', @@ -116,30 +88,45 @@ const ReflectionBodyContainer = ({ setSelectedVerseNumber={setSelectedVerseNumber} scrollToTop={scrollToTop} selectedContentType={selectedContentType} + isModal={isModal} /> ), - [scrollToTop, selectedChapterId, selectedVerseNumber, selectedContentType], + [scrollToTop, selectedChapterId, selectedVerseNumber, selectedContentType, isModal], + ); + + const dataFetcher = ( + ); const body = ( - <> - {/* @ts-ignore */} - - + - + {isModal ?
{dataFetcher}
: dataFetcher} +
); return render({ diff --git a/src/components/QuranReader/ReflectionView/ReflectionItem/AuthorInfo/index.tsx b/src/components/QuranReader/ReflectionView/ReflectionItem/AuthorInfo/index.tsx index 03ab6bab2b..772e6d45d7 100644 --- a/src/components/QuranReader/ReflectionView/ReflectionItem/AuthorInfo/index.tsx +++ b/src/components/QuranReader/ReflectionView/ReflectionItem/AuthorInfo/index.tsx @@ -53,15 +53,15 @@ const AuthorInfo: React.FC = ({ logButtonClick('reflection_item_author'); }; + const handleImageError = useCallback(() => { + setImageError(true); + }, []); + const referredVerseText = useMemo( () => buildReferredVerseText(verseReferences, nonChapterVerseReferences, lang, t), [verseReferences, nonChapterVerseReferences, lang, t], ); - const handleImageError = useCallback(() => { - setImageError(true); - }, []); - return (
diff --git a/src/components/QuranReader/SidebarNavigation/PageSelection.tsx b/src/components/QuranReader/SidebarNavigation/PageSelection.tsx index f1c605ed89..1ed34c544a 100644 --- a/src/components/QuranReader/SidebarNavigation/PageSelection.tsx +++ b/src/components/QuranReader/SidebarNavigation/PageSelection.tsx @@ -9,7 +9,7 @@ import { getPageNavigationUrl } from '@/utils/navigation'; import { getPageIdsByMushaf } from '@/utils/page'; type Props = { - onAfterNavigationItemRouted?: () => void; + onAfterNavigationItemRouted?: (itemValue?: string, itemType?: string) => void; }; const PageSelection: React.FC = ({ onAfterNavigationItemRouted }) => { diff --git a/src/components/QuranReader/SidebarNavigation/ScrollableSelection.tsx b/src/components/QuranReader/SidebarNavigation/ScrollableSelection.tsx index 49f3153171..8774725af0 100644 --- a/src/components/QuranReader/SidebarNavigation/ScrollableSelection.tsx +++ b/src/components/QuranReader/SidebarNavigation/ScrollableSelection.tsx @@ -7,6 +7,7 @@ import styles from './SidebarNavigation.module.scss'; import Link from '@/dls/Link/Link'; import { SCROLL_TO_NEAREST_ELEMENT, useScrollToElement } from '@/hooks/useScrollToElement'; +import NavigationItemType from '@/types/NavigationItemType'; import SearchQuerySource from '@/types/SearchQuerySource'; import { logEmptySearchResults, logTextSearchQuery } from '@/utils/eventLogger'; @@ -51,27 +52,27 @@ const ScrollableSelection = ({ scroll(); }, [selectedItem, scroll]); - const navigateAndHandleAfterNavigation = (href: string) => { + const navigateAndHandleAfterNavigation = (href: string, itemValue: string | number) => { router.push(href).then(() => { if (onAfterNavigationItemRouted) { - onAfterNavigationItemRouted(); + const itemType = isJuz ? NavigationItemType.JUZ : NavigationItemType.PAGE; + onAfterNavigationItemRouted(itemValue?.toString(), itemType); } }); }; - // handle when user press `Enter` in input box - const handleInputSubmit = (e) => { + const handleInputSubmit = (e: React.FormEvent) => { e.preventDefault(); const firstFilteredItem = filteredItems[0]; - if (filteredItems) { + if (firstFilteredItem) { const href = getHref(firstFilteredItem.value); - navigateAndHandleAfterNavigation(href); + navigateAndHandleAfterNavigation(href, firstFilteredItem.value); } }; - const handleItemClick = (e: React.MouseEvent, href: string) => { + const handleItemClick = (e: React.MouseEvent, href: string, itemValue: string | number) => { e.preventDefault(); - navigateAndHandleAfterNavigation(href); + navigateAndHandleAfterNavigation(href, itemValue); }; return ( @@ -93,7 +94,7 @@ const ScrollableSelection = ({ href={href} key={item.value} shouldPrefetch={false} - onClick={(e) => handleItemClick(e, href)} + onClick={(e) => handleItemClick(e, href, item.value)} >
{ const dispatch = useDispatch(); const { t } = useTranslation('common'); const sidebarRef = useRef(); + const [shouldDelayVisibleState, setShouldDelayVisibleState] = useState( + () => isSidebarVisible === true || isSidebarVisible === 'auto', + ); + useEffect(() => { + if (!shouldDelayVisibleState) return undefined; + const animationFrameId = requestAnimationFrame(() => { + setShouldDelayVisibleState(false); + }); + return () => cancelAnimationFrame(animationFrameId); + }, [shouldDelayVisibleState]); useOutsideClickDetector( sidebarRef, @@ -69,13 +80,15 @@ const SidebarNavigation = () => { }, ]; - const isSidebarAuto = isSidebarVisible === 'auto'; - const showSidebar = isSidebarVisible === true; + const isSidebarAuto = isSidebarVisible === 'auto' && !shouldDelayVisibleState; + const showSidebar = isSidebarVisible === true && !shouldDelayVisibleState; + const isSidebarActive = Boolean(isSidebarVisible) && !shouldDelayVisibleState; return (
{ [styles.containerAutoCollapsed]: isSidebarAuto && !isNavbarVisible, [styles.inVisibleContainer]: isNavbarVisible, [styles.inVisibleContainerCollapsed]: !isNavbarVisible, - [styles.spaceOnTop]: isSidebarVisible && isNavbarVisible, - [styles.spaceOnTopCollapsed]: isSidebarVisible && !isNavbarVisible, + [styles.spaceOnTop]: isSidebarActive && isNavbarVisible, + [styles.spaceOnTopCollapsed]: isSidebarActive && !isNavbarVisible, })} > {!isReadingByRevelationOrder ? ( diff --git a/src/components/QuranReader/SidebarNavigation/SidebarNavigationSelections.tsx b/src/components/QuranReader/SidebarNavigation/SidebarNavigationSelections.tsx index 69363aa9ec..8c6ee3f05b 100644 --- a/src/components/QuranReader/SidebarNavigation/SidebarNavigationSelections.tsx +++ b/src/components/QuranReader/SidebarNavigation/SidebarNavigationSelections.tsx @@ -1,16 +1,22 @@ -import React from 'react'; +import React, { useContext } from 'react'; import dynamic from 'next/dynamic'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import SidebarSelectionSkeleton from './SidebarSelectionSkeleton'; +import { + selectLastReadVerseKey, + setLastReadVerse, +} from '@/redux/slices/QuranReader/readingTracker'; import { IsSidebarNavigationVisible, NavigationItem, setIsSidebarNavigationVisible, } from '@/redux/slices/QuranReader/sidebarNavigation'; +import NavigationItemType from '@/types/NavigationItemType'; import { isMobile } from '@/utils/responsive'; +import DataContext from 'src/contexts/DataContext'; const PageSelection = dynamic(() => import('./PageSelection'), { loading: SidebarSelectionSkeleton, @@ -32,14 +38,55 @@ type Props = { const SidebarNavigationSelections: React.FC = ({ isVisible, selectedNavigationItem }) => { const dispatch = useDispatch(); + const chaptersData = useContext(DataContext); + const lastReadVerseKey = useSelector(selectLastReadVerseKey); // we skip requesting any selection list if the drawer is not open. if (!isVisible) return <>; - const onAfterNavigationItemRouted = () => { - const isDeviceMobile = isMobile(); - // close the sidebar if the device is mobile after navigation - if (isDeviceMobile) { - dispatch({ type: setIsSidebarNavigationVisible.type, payload: false }); + const updateReduxStateWithPage = (pageNumber: string) => { + dispatch( + setLastReadVerse({ + lastReadVerse: { + ...lastReadVerseKey, + page: pageNumber, + // Clear chapter and verse context when navigating by page + // to ensure consistent Redux state + verseKey: null, + chapterId: null, + }, + chaptersData, + }), + ); + }; + + const updateReduxWithChapterOnly = (chapterId: string) => { + dispatch( + setLastReadVerse({ + lastReadVerse: { + ...lastReadVerseKey, + verseKey: `${chapterId}:1`, + chapterId, + }, + chaptersData, + }), + ); + }; + + const updateReduxStateWithChapter = (chapterId: string) => { + updateReduxWithChapterOnly(chapterId); + }; + + const onAfterNavigationItemRouted = (itemValue?: string, itemType?: string) => { + if (isMobile()) { + dispatch(setIsSidebarNavigationVisible(false)); + } + + if (itemValue) { + if (itemType === NavigationItemType.PAGE) { + updateReduxStateWithPage(itemValue); + } else if (itemType === NavigationItemType.CHAPTER) { + updateReduxStateWithChapter(itemValue); + } } }; diff --git a/src/components/QuranReader/SidebarNavigation/SurahList.tsx b/src/components/QuranReader/SidebarNavigation/SurahList.tsx index 2f01121376..aded323c43 100644 --- a/src/components/QuranReader/SidebarNavigation/SurahList.tsx +++ b/src/components/QuranReader/SidebarNavigation/SurahList.tsx @@ -14,6 +14,7 @@ import useChapterIdsByUrlPath from '@/hooks/useChapterId'; import { SCROLL_TO_NEAREST_ELEMENT, useScrollToElement } from '@/hooks/useScrollToElement'; import { selectLastReadVerseKey } from '@/redux/slices/QuranReader/readingTracker'; import { selectIsReadingByRevelationOrder } from '@/redux/slices/revelationOrder'; +import NavigationItemType from '@/types/NavigationItemType'; import SearchQuerySource from '@/types/SearchQuerySource'; import { logEmptySearchResults, logTextSearchQuery } from '@/utils/eventLogger'; import { toLocalizedNumber } from '@/utils/locale'; @@ -42,7 +43,7 @@ const filterSurah = (surahs: Chapter[], searchQuery: string) => { }; type Props = { - onAfterNavigationItemRouted?: () => void; + onAfterNavigationItemRouted?: (itemValue?: string, itemType?: string) => void; customChapterSelectHandler?: (chapterId: string) => void; shouldDisableNavigation?: boolean; selectedChapterId?: string; @@ -122,10 +123,10 @@ const SurahList: React.FC = ({ scrollTo(); }, [currentChapterId, scrollTo]); - const navigateAndHandleAfterNavigation = (href: string) => { + const navigateAndHandleAfterNavigation = (href: string, chapterId: string) => { router.push(href).then(() => { if (onAfterNavigationItemRouted) { - onAfterNavigationItemRouted(); + onAfterNavigationItemRouted(chapterId, NavigationItemType.CHAPTER); } }); }; @@ -136,7 +137,7 @@ const SurahList: React.FC = ({ const firstFilteredChapter = filteredChapters[0]; if (firstFilteredChapter) { const href = getSurahNavigationUrl(firstFilteredChapter.id); - navigateAndHandleAfterNavigation(href); + navigateAndHandleAfterNavigation(href, firstFilteredChapter.id.toString()); } }; @@ -150,7 +151,7 @@ const SurahList: React.FC = ({ } // Otherwise use the default navigation behavior - navigateAndHandleAfterNavigation(href); + navigateAndHandleAfterNavigation(href, chapterId); }; return ( diff --git a/src/components/QuranReader/SidebarNavigation/SurahSelection.tsx b/src/components/QuranReader/SidebarNavigation/SurahSelection.tsx index 6a6f49d2f9..400dca26fb 100644 --- a/src/components/QuranReader/SidebarNavigation/SurahSelection.tsx +++ b/src/components/QuranReader/SidebarNavigation/SurahSelection.tsx @@ -2,7 +2,7 @@ import styles from './SidebarNavigation.module.scss'; import SurahList from './SurahList'; type Props = { - onAfterNavigationItemRouted?: () => void; + onAfterNavigationItemRouted?: (itemValue?: string, itemType?: string) => void; }; const SurahSelection: React.FC = ({ onAfterNavigationItemRouted }) => { diff --git a/src/components/QuranReader/SidebarNavigation/VerseList.tsx b/src/components/QuranReader/SidebarNavigation/VerseList.tsx index a530652f4e..d78b608b18 100644 --- a/src/components/QuranReader/SidebarNavigation/VerseList.tsx +++ b/src/components/QuranReader/SidebarNavigation/VerseList.tsx @@ -131,7 +131,7 @@ const VerseList: React.FC = ({ onAfterNavigationItemRouted, selectedChapt />
-
+
{filteredVerseKeys.map((verseKey) => ( onTafsirSelected(tafsir.id, tafsir.slug)} size={ButtonSize.Small} key={tafsir.id} + data-testid={`tafsir-selection-${tafsir.slug}`} + data-selected={selected} className={classNames(styles.tafsirSelectionItem, { [styles.tafsirItemSelected]: selected, })} diff --git a/src/components/QuranReader/TafsirView/TafsirVerseAction.tsx b/src/components/QuranReader/TafsirView/TafsirVerseAction.tsx index df5ea52684..ed90513849 100644 --- a/src/components/QuranReader/TafsirView/TafsirVerseAction.tsx +++ b/src/components/QuranReader/TafsirView/TafsirVerseAction.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; @@ -38,6 +38,7 @@ const TafsirVerseAction = ({ const router = useRouter(); const contentModalRef = useRef(); + const closeTimeoutRef = useRef>(); const onModalClose = () => { if (isTranslationView) { @@ -48,13 +49,24 @@ const TafsirVerseAction = ({ setIsContentModalOpen(false); fakeNavigate(router.asPath, router.locale); if (onActionTriggered) { - setTimeout(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + closeTimeoutRef.current = setTimeout(() => { // we set a really short timeout to close the popover after the modal has been closed to allow enough time for the fadeout css effect to apply. onActionTriggered(); }, CLOSE_POPOVER_AFTER_MS); } }; + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + return ( <> void; + hasTranslationsButton?: boolean; +}; + +/** + * Common action buttons used in both translation and reading views + * Extracted to reduce duplication and improve maintainability + * @returns {JSX.Element} JSX element containing the action buttons + */ +const ActionButtons: React.FC = ({ + verse, + bookmarksRangeUrl, + hasNotes, + isTranslationView = true, + openShareModal, + hasTranslationsButton = false, +}) => { + return ( + <> +
{children}
} + > + <> + + + + + + + + + + {hasTranslationsButton && ( + + + + )} + +
+ +
{children}
} + > + <> + + + + + + + + + + + + + + +
+ + ); +}; + +export default ActionButtons; diff --git a/src/components/QuranReader/TranslationView/ActionItem.tsx b/src/components/QuranReader/TranslationView/ActionItem.tsx new file mode 100644 index 0000000000..205716fe23 --- /dev/null +++ b/src/components/QuranReader/TranslationView/ActionItem.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import styles from './TranslationViewCell.module.scss'; + +type ActionItemProps = { + children: React.ReactNode; +}; + +/** + * ActionItem component for wrapping action buttons in the Quran Reader + * @param {object} props - Component props + * @param {React.ReactNode} props.children - The content to be rendered inside the action item + * @returns {JSX.Element} JSX element containing the action item + */ +const ActionItem: React.FC = ({ children }) => ( +
{children}
+); + +export default ActionItem; diff --git a/src/components/QuranReader/TranslationView/BookmarkIcon.tsx b/src/components/QuranReader/TranslationView/BookmarkIcon.tsx index fd2b9f3c28..92ad4fee56 100644 --- a/src/components/QuranReader/TranslationView/BookmarkIcon.tsx +++ b/src/components/QuranReader/TranslationView/BookmarkIcon.tsx @@ -1,92 +1,49 @@ -/* eslint-disable react-func/max-lines-per-function */ -import { useMemo } from 'react'; - import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; -import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import { useSWRConfig } from 'swr'; +import { shallowEqual, useSelector } from 'react-redux'; import styles from './TranslationViewCell.module.scss'; import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; -import { ToastStatus, useToast } from '@/dls/Toast/Toast'; +import useVerseBookmark from '@/hooks/useVerseBookmark'; import BookmarkedIcon from '@/icons/bookmark.svg'; -import { selectBookmarks, toggleVerseBookmark } from '@/redux/slices/QuranReader/bookmarks'; import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; import { getMushafId } from '@/utils/api'; -import { deleteBookmarkById } from '@/utils/auth/api'; -import { makeBookmarkUrl } from '@/utils/auth/apiPaths'; -import { isLoggedIn } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; -import BookmarksMap from 'types/BookmarksMap'; -import BookmarkType from 'types/BookmarkType'; import Verse from 'types/Verse'; type Props = { verse: Verse; - pageBookmarks: BookmarksMap | undefined; bookmarksRangeUrl: string; }; -const BookmarkIcon: React.FC = ({ verse, pageBookmarks, bookmarksRangeUrl }) => { +/** + * BookmarkIcon component that shows a bookmark icon for bookmarked verses. + * Only renders when the verse is bookmarked. + * + * @returns {JSX.Element | null} The bookmark icon button or null if not bookmarked + */ +const BookmarkIcon: React.FC = ({ verse, bookmarksRangeUrl }) => { const { t } = useTranslation('quran-reader'); const quranReaderStyles = useSelector(selectQuranReaderStyles, shallowEqual); - const bookmarkedVerses = useSelector(selectBookmarks, shallowEqual); - const { cache, mutate } = useSWRConfig(); - const toast = useToast(); - const dispatch = useDispatch(); - - const isVerseBookmarked = useMemo(() => { - const isUserLoggedIn = isLoggedIn(); - if (isUserLoggedIn && pageBookmarks) { - return !!pageBookmarks[verse.verseKey]; - } - return !!bookmarkedVerses[verse.verseKey]; - }, [bookmarkedVerses, pageBookmarks, verse.verseKey]); + const mushafId = getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf; + const { isVerseBookmarked, handleToggleBookmark } = useVerseBookmark({ + verse: { + verseKey: verse.verseKey, + verseNumber: verse.verseNumber, + chapterId: verse.chapterId, + }, + mushafId, + bookmarksRangeUrl, + }); + + // Only show the icon when the verse is bookmarked if (!isVerseBookmarked) return null; - const mushafId = getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf; - const onClick = () => { logButtonClick('translation_view_un_bookmark_verse'); - - if (isLoggedIn()) { - const bookmarkedVersesRange = cache.get(bookmarksRangeUrl); - const nextBookmarkedVersesRange = { - ...bookmarkedVersesRange, - [verse.verseKey]: !isVerseBookmarked, - }; - mutate(bookmarksRangeUrl, nextBookmarkedVersesRange, { - revalidate: false, - }); - - cache.delete( - makeBookmarkUrl( - mushafId, - Number(verse.chapterId), - BookmarkType.Ayah, - Number(verse.verseNumber), - ), - ); - - const bookmarkId = pageBookmarks[verse.verseKey].id; - if (bookmarkId) { - deleteBookmarkById(bookmarkId).catch((err) => { - if (err.status === 400) { - toast(t('common:error.bookmark-sync'), { - status: ToastStatus.Error, - }); - return; - } - toast(t('common:error.general'), { - status: ToastStatus.Error, - }); - }); - } - } else { - dispatch(toggleVerseBookmark(verse.verseKey)); - } + handleToggleBookmark(); }; return ( diff --git a/src/components/QuranReader/TranslationView/BottomActions.tsx b/src/components/QuranReader/TranslationView/BottomActions.tsx new file mode 100644 index 0000000000..fd723e1e00 --- /dev/null +++ b/src/components/QuranReader/TranslationView/BottomActions.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { useSelector } from 'react-redux'; + +import BottomActionsModals, { ModalType } from './BottomActionsModals'; +import BottomActionsTabs, { TabId } from './BottomActionsTabs'; + +import { usePageQuestions } from '@/components/QuranReader/ReadingView/context/PageQuestionsContext'; +import useIsMobile, { MobileSizeVariant } from '@/hooks/useIsMobile'; +import BookIcon from '@/icons/book-open.svg'; +import ChatIcon from '@/icons/chat.svg'; +import LightbulbOnIcon from '@/icons/lightbulb-on.svg'; +import LightbulbIcon from '@/icons/lightbulb.svg'; +import { selectSelectedTafsirs } from '@/redux/slices/QuranReader/tafsirs'; +import QuestionType from '@/types/QuestionsAndAnswers/QuestionType'; +import { logButtonClick } from '@/utils/eventLogger'; +import { + fakeNavigate, + getVerseAnswersNavigationUrl, + getVerseReflectionNavigationUrl, + getVerseSelectedTafsirNavigationUrl, +} from '@/utils/navigation'; +import { getVerseAndChapterNumbersFromKey } from '@/utils/verse'; + +/** + * Props for the BottomActions component + */ +interface BottomActionsProps { + /** + * The verse key to display actions for + */ + verseKey: string; + /** + * Whether this is in translation view + */ + isTranslationView?: boolean; +} + +/** + * BottomActions component displays action tabs for a verse + * @param {BottomActionsProps} props - Component props + * @returns {JSX.Element} The rendered component + */ +const BottomActions = ({ verseKey, isTranslationView = true }: BottomActionsProps): JSX.Element => { + const { t, lang } = useTranslation('common'); + const tafsirs = useSelector(selectSelectedTafsirs); + const [chapterId, verseNumber] = getVerseAndChapterNumbersFromKey(verseKey); + const questionsData = usePageQuestions(); + const hasQuestions = questionsData?.[verseKey]?.total > 0; + const isClarificationQuestion = !!questionsData?.[verseKey]?.types?.[QuestionType.CLARIFICATION]; + const isMobile = useIsMobile(MobileSizeVariant.SMALL); + // Modal state using enum + const [openedModal, setOpenedModal] = useState(null); + + /** + * Handle tab click or keyboard event + * @param {TabId} tabType - Type of tab for logging + * @param {() => string} navigationFn - Function that returns navigation URL + * @returns {(e: React.MouseEvent | React.KeyboardEvent) => void} Event handler function + */ + const createTabHandler = (tabType: TabId, navigationFn: () => string) => { + return () => { + // Open the corresponding modal + if (tabType === TabId.TAFSIR) { + setOpenedModal(ModalType.TAFSIR); + } else if (tabType === TabId.REFLECTIONS) { + setOpenedModal(ModalType.REFLECTION); + } else if (tabType === TabId.ANSWERS) { + setOpenedModal(ModalType.QUESTIONS); + } + + logButtonClick( + `${ + isTranslationView ? 'translation_view' : 'reading_view' + }_verse_bottom_actions_${tabType}`, + ); + + // Update URL without triggering navigation + fakeNavigate(navigationFn(), lang); + }; + }; + + // Define tab configurations + const tabs = [ + { + id: TabId.TAFSIR, + label: t('quran-reader:tafsirs'), + icon: , + onClick: createTabHandler(TabId.TAFSIR, () => + getVerseSelectedTafsirNavigationUrl(chapterId, Number(verseNumber), tafsirs[0]), + ), + condition: true, + }, + { + id: TabId.REFLECTIONS, + label: isMobile ? t('reflections') : t('reflections-and-lessons'), + icon: , + onClick: createTabHandler(TabId.REFLECTIONS, () => getVerseReflectionNavigationUrl(verseKey)), + condition: true, + }, + { + id: TabId.ANSWERS, + label: t('answers'), + icon: isClarificationQuestion ? : , + onClick: createTabHandler(TabId.ANSWERS, () => getVerseAnswersNavigationUrl(verseKey)), + condition: hasQuestions, + }, + ]; + + return ( + <> + + + setOpenedModal(null)} + /> + + ); +}; + +export default BottomActions; diff --git a/src/components/QuranReader/TranslationView/BottomActionsModals.module.scss b/src/components/QuranReader/TranslationView/BottomActionsModals.module.scss new file mode 100644 index 0000000000..ae923f7880 --- /dev/null +++ b/src/components/QuranReader/TranslationView/BottomActionsModals.module.scss @@ -0,0 +1,30 @@ +@use 'src/styles/breakpoints'; + +.reflectionOverlay .reflectionContentModal { + padding: 0; + .reflectionHeader { + border-radius: var(--border-radius-rounded); + padding-inline: var(--spacing-medium-px); + padding-block: var(--spacing-small-px); + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + align-items: center; + + @include breakpoints.tablet { + padding-inline: var(--spacing-large-px); + padding-block: var(--spacing-medium-px); + } + + .reflectionHeaderCloseIcon { + position: relative; + inset-block: unset; + inset-inline: unset; + transform: unset; + } + } + // Remove default padding to allow full-width tabs in reflection modal + .reflectionInnerContentModal { + padding-inline: 0; + } +} diff --git a/src/components/QuranReader/TranslationView/BottomActionsModals.tsx b/src/components/QuranReader/TranslationView/BottomActionsModals.tsx new file mode 100644 index 0000000000..50cc48a258 --- /dev/null +++ b/src/components/QuranReader/TranslationView/BottomActionsModals.tsx @@ -0,0 +1,128 @@ +import React, { useRef } from 'react'; + +import { useRouter } from 'next/router'; +import useTranslation from 'next-translate/useTranslation'; + +import styles from './BottomActionsModals.module.scss'; + +import ContentModal from '@/components/dls/ContentModal/ContentModal'; +import QuestionsModal from '@/components/QuestionAndAnswer/QuestionsModal'; +import ReflectionBodyContainer from '@/components/QuranReader/ReflectionView/ReflectionBodyContainer'; +import TafsirBody from '@/components/QuranReader/TafsirView/TafsirBody'; +import { logEvent } from '@/utils/eventLogger'; +import { fakeNavigate } from '@/utils/navigation'; + +/** + * Enum for modal types + */ +export enum ModalType { + TAFSIR = 'tafsir', + REFLECTION = 'reflection', + QUESTIONS = 'questions', +} + +interface BottomActionsModalsProps { + chapterId: string; + verseNumber: string; + verseKey: string; + tafsirs: string[]; + openedModal: ModalType | null; + hasQuestions: boolean; + isTranslationView: boolean; + onCloseModal: () => void; +} + +const BottomActionsModals: React.FC = ({ + chapterId, + verseNumber, + verseKey, + tafsirs, + openedModal, + hasQuestions, + isTranslationView, + onCloseModal, +}) => { + const { t } = useTranslation('common'); + const router = useRouter(); + + // Refs for content modals + const tafsirModalRef = useRef(null); + const reflectionModalRef = useRef(null); + + // Modal close handlers + const handleModalClose = (modalType: ModalType) => { + logEvent(`${isTranslationView ? 'translation_view' : 'reading_view'}_${modalType}_modal_close`); + onCloseModal(); + fakeNavigate(router.asPath, router.locale); + }; + + const onQuestionsModalClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + return ( + <> + {/* Tafsir Modal */} + { + tafsirModalRef.current?.scrollToTop(); + }} + shouldRender={openedModal === ModalType.TAFSIR} + render={({ surahAndAyahSelection, languageAndTafsirSelection, body }) => ( + handleModalClose(ModalType.TAFSIR)} + header={t('quran-reader:tafsirs')} + > + {surahAndAyahSelection} + {languageAndTafsirSelection} + {body} + + )} + /> + + {/* Reflection Modal */} + { + reflectionModalRef.current?.scrollToTop(); + }} + render={({ surahAndAyahSelection, body }) => ( + handleModalClose(ModalType.REFLECTION)} + hasCloseButton + header={surahAndAyahSelection} + headerClassName={styles.reflectionHeader} + closeIconClassName={styles.reflectionHeaderCloseIcon} + > + {body} + + )} + /> + + {/* Questions Modal */} + {openedModal === ModalType.QUESTIONS && hasQuestions && ( + handleModalClose(ModalType.QUESTIONS)} + verseKey={verseKey} + onModalClick={onQuestionsModalClick} + /> + )} + + ); +}; + +export default BottomActionsModals; diff --git a/src/components/QuranReader/TranslationView/BottomActionsTabs.tsx b/src/components/QuranReader/TranslationView/BottomActionsTabs.tsx new file mode 100644 index 0000000000..72b248b648 --- /dev/null +++ b/src/components/QuranReader/TranslationView/BottomActionsTabs.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; + +import styles from './TranslationViewCell.module.scss'; + +import Separator, { SeparatorWeight } from '@/components/dls/Separator/Separator'; +import { isRTLLocale } from '@/utils/locale'; + +export enum TabId { + TAFSIR = 'tafsir', + REFLECTIONS = 'reflections', + ANSWERS = 'answers', +} + +export interface TabConfig { + id: TabId; + label: string; + icon: JSX.Element; + onClick: (e: React.MouseEvent | React.KeyboardEvent) => void; + condition: boolean | undefined; +} + +interface BottomActionsTabsProps { + tabs: TabConfig[]; + isTranslationView: boolean; +} + +const BottomActionsTabs: React.FC = ({ tabs, isTranslationView }) => { + const { lang } = useTranslation(); + const isRTL = isRTLLocale(lang); + + const handleTabClick = ( + e: React.MouseEvent, + onClick: (e: React.MouseEvent | React.KeyboardEvent) => void, + ) => { + onClick(e); + }; + + const handleTabKeyDown = ( + e: React.KeyboardEvent, + onClick: (e: React.MouseEvent | React.KeyboardEvent) => void, + ) => { + onClick(e); + }; + + return ( +
+
+ {tabs + .filter((tab) => tab.condition !== false) // Only show tabs that meet their condition + .map((tab, index, filteredTabs) => ( + +
handleTabClick(e, tab.onClick)} + onKeyDown={(e) => handleTabKeyDown(e, tab.onClick)} + role="button" + tabIndex={0} + aria-label={tab.label} + > + {tab.icon} + {tab.label} +
+ {index < filteredTabs.length - 1 && ( +
+ +
+ )} +
+ ))} +
+
+ ); +}; + +export default BottomActionsTabs; diff --git a/src/components/QuranReader/TranslationView/TopActions.tsx b/src/components/QuranReader/TranslationView/TopActions.tsx new file mode 100644 index 0000000000..810a128a99 --- /dev/null +++ b/src/components/QuranReader/TranslationView/TopActions.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useState } from 'react'; + +import ShareQuranModal from '../ReadingView/ShareQuranModal'; + +import ActionButtons from './ActionButtons'; +import styles from './TranslationViewCell.module.scss'; + +import { WordVerse } from '@/types/Word'; +import { logEvent } from '@/utils/eventLogger'; + +type TopActionsProps = { + verse: WordVerse; + bookmarksRangeUrl: string; + hasNotes?: boolean; + isTranslationView?: boolean; +}; + +/** + * Top actions component for the TranslationView and ReadingView Mobile + * Contains verse navigation, bookmarking, translations, copy, notes, play audio, and overflow menu + * @returns {JSX.Element} JSX element containing the top action buttons + */ +const TopActions: React.FC = ({ + verse, + bookmarksRangeUrl, + hasNotes, + isTranslationView = true, +}) => { + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + + const onOpenModalChange = useCallback( + (isOpen: boolean) => { + logEvent( + `${isTranslationView ? 'translation' : 'reading'}_view_verse_actions_share_modal_${ + isOpen ? 'open' : 'close' + }`, + ); + setIsShareModalOpen(isOpen); + }, + [isTranslationView], + ); + + return ( + <> +
+ onOpenModalChange(true)} + hasTranslationsButton={!isTranslationView} + /> +
+ + onOpenModalChange(false)} + verse={verse} + /> + + ); +}; + +export default TopActions; diff --git a/src/components/QuranReader/TranslationView/TranslationText/FootnoteText.tsx b/src/components/QuranReader/TranslationView/TranslationText/FootnoteText.tsx index c0fd07b97e..67fbe29268 100644 --- a/src/components/QuranReader/TranslationView/TranslationText/FootnoteText.tsx +++ b/src/components/QuranReader/TranslationView/TranslationText/FootnoteText.tsx @@ -42,7 +42,7 @@ const FootnoteText: React.FC = ({ }, [footnote?.text]); return ( -
+

{t('footnote')} {footnoteName ? `- ${footnoteName}` : null} diff --git a/src/components/QuranReader/TranslationView/TranslationText/TranslationText.module.scss b/src/components/QuranReader/TranslationView/TranslationText/TranslationText.module.scss index a338f22902..f4ddc6205b 100644 --- a/src/components/QuranReader/TranslationView/TranslationText/TranslationText.module.scss +++ b/src/components/QuranReader/TranslationView/TranslationText/TranslationText.module.scss @@ -63,4 +63,3 @@ $translation-line-height: 1.5; opacity: var(--opacity-50); } } - diff --git a/src/components/QuranReader/TranslationView/TranslationViewCell.module.scss b/src/components/QuranReader/TranslationView/TranslationViewCell.module.scss index b97ae055bb..3d794f0352 100644 --- a/src/components/QuranReader/TranslationView/TranslationViewCell.module.scss +++ b/src/components/QuranReader/TranslationView/TranslationViewCell.module.scss @@ -1,5 +1,7 @@ -@use "src/styles/breakpoints"; -@use "src/styles/utility"; +@use 'src/styles/breakpoints'; +@use 'src/styles/utility'; + +$arabicVerseTopMargin: 2.75rem; .cellContainer { display: flex; @@ -8,11 +10,6 @@ direction: ltr; padding: var(--spacing-small); --gap-size: calc(0.5 * var(--spacing-mega)); - @include breakpoints.tablet { - --gap-size: calc(1.5 * var(--spacing-mega)); - flex-direction: row; - padding: 0; - } } .highlightedContainer { @@ -29,22 +26,21 @@ display: flex; direction: ltr; flex-direction: row; + align-items: center; justify-content: space-between; - @include breakpoints.tablet { - flex-direction: column; - justify-content: start; - margin-inline-end: calc(2 * var(--spacing-mega)); + + @include breakpoints.smallerThanTablet { + gap: var(--spacing-xxsmall-px); } - align-items: center; } .arabicVerseContainer { direction: rtl; padding-block-start: var(--spacing-xxsmall); - margin-block-start: var(--gap-size); + margin-block-start: $arabicVerseTopMargin; margin-block-end: var(--gap-size); } .verseTranslationsContainer { - margin-block-end: calc(1.3 * var(--gap-size)); + margin-block-end: calc(var(--spacing-medium2) * 3); } .verseTranslationContainer { @@ -55,10 +51,7 @@ .actionContainerRight { display: flex; align-items: center; - @include breakpoints.tablet { - flex-direction: column; - justify-content: center; - } + gap: var(--spacing-xxsmall-px); } .actionContainerLeft { justify-content: flex-start; @@ -69,8 +62,8 @@ .iconContainer.verseAction { color: var(--color-text-default); - height: calc(2.2 * var(--spacing-medium)); - width: calc(2.2 * var(--spacing-medium)); + block-size: calc(2.2 * var(--spacing-medium)); + inline-size: calc(2.2 * var(--spacing-medium)); &:hover { color: var(--color-text-default); @@ -80,16 +73,25 @@ @media (hover: hover) { &:hover { - opacity: 1; color: var(--color-success-medium); background-color: var(--color-success-medium); @include utility.lighten-background-color; + + .icon span { + svg { + color: var(--color-success-medium) !important; + } + } } } } -.fadedVerseAction { - opacity: var(--opacity-50); +.overlayModal { + z-index: var(--z-index-header); +} + +.menuOffset { + margin-inline-end: calc(var(--spacing-medium) * 1.125); } .icon { @@ -97,8 +99,21 @@ align-items: center; justify-content: center; svg { - width: calc(1.1 * var(--spacing-medium)); - height: calc(1.1 * var(--spacing-medium)); + inline-size: var(--spacing-medium2); + block-size: var(--spacing-medium2); + } + + @include breakpoints.smallerThanTablet { + inline-size: var(--spacing-medium); + block-size: var(--spacing-medium); + } +} + +.actionItem { + &:hover { + svg { + color: var(--color-success-medium); + } } } @@ -108,13 +123,6 @@ margin-left: calc(0.5 * var(--spacing-xxsmall)); } -.priorityAction { - order: 1; - @include breakpoints.tablet { - order: unset; - } -} - .actionContainerLeft .actionItem { display: inline-block; margin-inline-end: calc(0.5 * var(--spacing-xxsmall)); @@ -122,3 +130,86 @@ margin-inline-end: 0; } } + +.bottomActionsContainer { + inline-size: 100%; +} + +.tabsContainer { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-medium); + margin-block-end: var(--spacing-medium); + + @media (min-width: 768px) { + gap: var(--spacing-large); + } +} + +.tabsContainerRTL { + justify-content: flex-end; +} + +.center { + justify-content: center; +} + +.separatorContainer { + display: flex; + block-size: var(--spacing-medium); + align-items: center; +} + +.tabItem { + display: flex; + flex-direction: row; + align-items: center; + gap: calc(var(--spacing-xsmall) / 2); + cursor: pointer; + color: var(--color-grey-icons-new); + transition: color 0.2s ease; + + &:hover { + color: var(--color-success-medium); + } +} + +.tabItemRTL { + flex-direction: row-reverse; +} + +.tabIcon { + display: flex; + align-items: center; + justify-content: center; + + svg { + inline-size: var(--spacing-medium2); + block-size: var(--spacing-medium2); + color: currentColor; + + &:hover { + color: currentColor; + } + + @include breakpoints.smallerThanTablet { + inline-size: var(--spacing-medium); + block-size: var(--spacing-medium); + } + } +} + +.tabLabel { + font-size: var(--font-size-medium); + white-space: nowrap; + + @include breakpoints.smallerThanTablet { + font-size: var(--font-size-small); + } +} + +.verseSeparator { + inline-size: calc(100% - 2 * var(--spacing-small)) !important; + margin-inline: auto; +} diff --git a/src/components/QuranReader/TranslationView/TranslationViewCell.tsx b/src/components/QuranReader/TranslationView/TranslationViewCell.tsx index eedac30d87..9110673527 100644 --- a/src/components/QuranReader/TranslationView/TranslationViewCell.tsx +++ b/src/components/QuranReader/TranslationView/TranslationViewCell.tsx @@ -1,37 +1,32 @@ -import React, { RefObject, memo, useContext, useEffect } from 'react'; +/* eslint-disable max-lines */ +import React, { memo, useContext, useEffect } from 'react'; import { useSelector as useSelectorXstate } from '@xstate/react'; import classNames from 'classnames'; import { useRouter } from 'next/router'; import { useSelector } from 'react-redux'; +import getTranslationsLabelString from '../ReadingView/utils/translation'; import { verseFontChanged, verseTranslationChanged, verseTranslationFontChanged, } from '../utils/memoization'; -import BookmarkIcon from './BookmarkIcon'; +import BottomActions from './BottomActions'; +import TopActions from './TopActions'; import TranslationText from './TranslationText'; import styles from './TranslationViewCell.module.scss'; import { useOnboarding } from '@/components/Onboarding/OnboardingProvider'; -import QuranReflectButton from '@/components/QuranReader/QuranReflectButton'; -import CopyButton from '@/components/QuranReader/ReadingView/CopyButton'; -import TafsirButton from '@/components/QuranReader/TafsirButton'; -import VerseNotes from '@/components/Verse/Notes'; -import OverflowVerseActionsMenu from '@/components/Verse/OverflowVerseActionsMenu'; -import PlayVerseAudioButton from '@/components/Verse/PlayVerseAudioButton'; -import VerseQuestions from '@/components/Verse/Questions'; -import VerseLink from '@/components/Verse/VerseLink'; import VerseText from '@/components/Verse/VerseText'; import Separator from '@/dls/Separator/Separator'; -import useScroll, { SMOOTH_SCROLL_TO_TOP } from '@/hooks/useScrollToElement'; +import useScrollWithContextMenuOffset from '@/hooks/useScrollWithContextMenuOffset'; import { selectEnableAutoScrolling } from '@/redux/slices/AudioPlayer/state'; import QuranReaderStyles from '@/redux/types/QuranReaderStyles'; -import { getVerseWords, makeVerseKey } from '@/utils/verse'; +import { WordVerse } from '@/types/Word'; +import { constructWordVerse, getVerseWords, makeVerseKey } from '@/utils/verse'; import { AudioPlayerMachineContext } from 'src/xstate/AudioPlayerMachineContext'; -import BookmarksMap from 'types/BookmarksMap'; import Translation from 'types/Translation'; import Verse from 'types/Verse'; @@ -39,26 +34,21 @@ type TranslationViewCellProps = { verse: Verse; quranReaderStyles: QuranReaderStyles; verseIndex: number; - pageBookmarks: BookmarksMap | undefined; bookmarksRangeUrl: string; hasNotes?: boolean; - hasQuestions?: boolean; }; const TranslationViewCell: React.FC = ({ verse, quranReaderStyles, verseIndex, - pageBookmarks, bookmarksRangeUrl, hasNotes, - hasQuestions, }) => { const router = useRouter(); const { startingVerse } = router.query; const audioService = useContext(AudioPlayerMachineContext); - const isHighlighted = useSelectorXstate(audioService, (state) => { const { ayahNumber, surah } = state.context; return makeVerseKey(surah, ayahNumber) === verse.verseKey; @@ -68,8 +58,8 @@ const TranslationViewCell: React.FC = ({ // disable auto scrolling when the user is onboarding const enableAutoScrolling = useSelector(selectEnableAutoScrolling) && !isActive; - const [scrollToSelectedItem, selectedItemRef]: [() => void, RefObject] = - useScroll(SMOOTH_SCROLL_TO_TOP); + // Use our custom hook that handles scrolling with context menu offset + const [scrollToSelectedItem, selectedItemRef] = useScrollWithContextMenuOffset(); useEffect(() => { if ((isHighlighted && enableAutoScrolling) || Number(startingVerse) === verseIndex + 1) { @@ -77,47 +67,19 @@ const TranslationViewCell: React.FC = ({ } }, [isHighlighted, scrollToSelectedItem, enableAutoScrolling, startingVerse, verseIndex]); + const translationsLabel = getTranslationsLabelString(verse.translations); + const translationsCount = verse.translations?.length || 0; + const wordVerse: WordVerse = constructWordVerse(verse, translationsLabel, translationsCount); + return (

-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
-
+
@@ -135,10 +97,10 @@ const TranslationViewCell: React.FC = ({
))}
-
+
- +
); }; @@ -174,8 +136,6 @@ const areVersesEqual = ( ) && !verseTranslationChanged(prevProps.verse, nextProps.verse) && !verseTranslationFontChanged(prevProps.quranReaderStyles, nextProps.quranReaderStyles) && - JSON.stringify(prevProps.pageBookmarks) === JSON.stringify(nextProps.pageBookmarks) && prevProps.bookmarksRangeUrl === nextProps.bookmarksRangeUrl && - prevProps.hasNotes === nextProps.hasNotes && - prevProps.hasQuestions === nextProps.hasQuestions; + prevProps.hasNotes === nextProps.hasNotes; export default memo(TranslationViewCell, areVersesEqual); diff --git a/src/components/QuranReader/TranslationView/TranslationViewVerse/TranslationPageVerse.tsx b/src/components/QuranReader/TranslationView/TranslationViewVerse/TranslationPageVerse.tsx index 59ee8502c9..a83f2eb219 100644 --- a/src/components/QuranReader/TranslationView/TranslationViewVerse/TranslationPageVerse.tsx +++ b/src/components/QuranReader/TranslationView/TranslationViewVerse/TranslationPageVerse.tsx @@ -1,30 +1,19 @@ import { useEffect, useRef } from 'react'; -import useTranslation from 'next-translate/useTranslation'; -import useSWRImmutable from 'swr/immutable'; - import { useVerseTrackerContext } from '../../contexts/VerseTrackerContext'; import TranslationViewCell from '../TranslationViewCell'; import ChapterHeader from '@/components/chapters/ChapterHeader'; +import getTranslationsLabelString from '@/components/QuranReader/ReadingView/utils/translation'; import useCountRangeNotes from '@/hooks/auth/useCountRangeNotes'; -import useCountRangeQuestions from '@/hooks/auth/useCountRangeQuestions'; import QuranReaderStyles from '@/redux/types/QuranReaderStyles'; -import { VersesResponse } from '@/types/ApiResponses'; -import Translation from '@/types/Translation'; import Verse from '@/types/Verse'; -import { getPageBookmarks } from '@/utils/auth/api'; -import { toLocalizedNumber } from '@/utils/locale'; interface TranslationPageVerse { verse: Verse; - selectedTranslations?: number[]; bookmarksRangeUrl: string | null; - mushafId: number; verseIdx: number; quranReaderStyles: QuranReaderStyles; - initialData: VersesResponse; - firstVerseInPage: Verse; isLastVerseInView: boolean; notesRange: { from: string; @@ -34,51 +23,16 @@ interface TranslationPageVerse { const TranslationPageVerse: React.FC = ({ verse, - selectedTranslations, bookmarksRangeUrl, - mushafId, verseIdx, quranReaderStyles, - initialData, - firstVerseInPage, isLastVerseInView, notesRange, }) => { - const { t, lang } = useTranslation('common'); const containerRef = useRef(null); const { verseKeysQueue } = useVerseTrackerContext(); - const { data: pageBookmarks } = useSWRImmutable(bookmarksRangeUrl, async () => { - const response = await getPageBookmarks( - mushafId, - Number(firstVerseInPage.chapterId), - Number(firstVerseInPage.verseNumber), - initialData.pagination.perPage, - ); - return response; - }); - const { data: notesCount } = useCountRangeNotes(notesRange); - const { data: questionsCount } = useCountRangeQuestions(notesRange); - - const getTranslationNameString = (translations?: Translation[]) => { - let translationName = t('settings.no-translation-selected'); - if (translations?.length === 1) translationName = translations?.[0].resourceName; - if (translations?.length === 2) { - translationName = t('settings.value-and-other', { - value: translations?.[0].resourceName, - othersCount: toLocalizedNumber(translations.length - 1, lang), - }); - } - if (translations?.length > 2) { - translationName = t('settings.value-and-others', { - value: translations?.[0].resourceName, - othersCount: toLocalizedNumber(translations.length - 1, lang), - }); - } - - return translationName; - }; useEffect(() => { let observer: IntersectionObserver = null; @@ -104,7 +58,6 @@ const TranslationPageVerse: React.FC = ({ }; }, [isLastVerseInView, verse, verseKeysQueue]); - const hasQuestions = questionsCount && questionsCount[verse.verseKey] > 0; const hasNotes = notesCount && notesCount[verse.verseKey] > 0; return ( @@ -116,11 +69,12 @@ const TranslationPageVerse: React.FC = ({ > {verse.verseNumber === 1 && ( 0} + isTranslationView /> )} @@ -129,10 +83,8 @@ const TranslationPageVerse: React.FC = ({ verse={verse} key={verse.id} quranReaderStyles={quranReaderStyles} - pageBookmarks={pageBookmarks} bookmarksRangeUrl={bookmarksRangeUrl} hasNotes={hasNotes} - hasQuestions={hasQuestions} />
); diff --git a/src/components/QuranReader/TranslationView/TranslationViewVerse/index.tsx b/src/components/QuranReader/TranslationView/TranslationViewVerse/index.tsx index a81e996925..645972d764 100644 --- a/src/components/QuranReader/TranslationView/TranslationViewVerse/index.tsx +++ b/src/components/QuranReader/TranslationView/TranslationViewVerse/index.tsx @@ -40,7 +40,7 @@ const TranslationViewVerse: React.FC = ({ }) => { const mushafId = getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf; - const { verse, firstVerseInPage, bookmarksRangeUrl, notesRange } = useDedupedFetchVerse({ + const { verse, bookmarksRangeUrl, notesRange } = useDedupedFetchVerse({ verseIdx, quranReaderDataType, quranReaderStyles, @@ -67,12 +67,8 @@ const TranslationViewVerse: React.FC = ({ isLastVerseInView={verseIdx + 1 === totalVerses} verse={verse} verseIdx={verseIdx} - mushafId={mushafId} quranReaderStyles={quranReaderStyles} - selectedTranslations={selectedTranslations} bookmarksRangeUrl={bookmarksRangeUrl} - initialData={initialData} - firstVerseInPage={firstVerseInPage} notesRange={notesRange} />
diff --git a/src/components/QuranReader/TranslationView/index.tsx b/src/components/QuranReader/TranslationView/index.tsx index c01218b3d6..8a806bae44 100644 --- a/src/components/QuranReader/TranslationView/index.tsx +++ b/src/components/QuranReader/TranslationView/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ /* eslint-disable react/no-multi-comp */ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; @@ -15,11 +15,13 @@ import TranslationViewVerse from './TranslationViewVerse'; import { PageQuestionsContext } from '@/components/QuranReader/ReadingView/context/PageQuestionsContext'; import Spinner from '@/dls/Spinner/Spinner'; +import useCountRangeQuestions from '@/hooks/auth/useCountRangeQuestions'; import useGetQueryParamOrReduxValue from '@/hooks/useGetQueryParamOrReduxValue'; import useGetQueryParamOrXstateValue from '@/hooks/useGetQueryParamOrXstateValue'; import useQcfFont from '@/hooks/useQcfFont'; import QuranReaderStyles from '@/redux/types/QuranReaderStyles'; import { QuranReaderDataType } from '@/types/QuranReader'; +import { QuestionsData } from '@/utils/auth/api'; import { VersesResponse } from 'types/ApiResponses'; import QueryParam from 'types/QueryParam'; import Verse from 'types/Verse'; @@ -37,7 +39,6 @@ const EndOfScrollingControls = dynamic(() => import('../EndOfScrollingControls') }); const INCREASE_VIEWPORT_BY_PIXELS = 1000; -const EMPTY_QUESTIONS = {} as Record; const TranslationView = ({ quranReaderStyles, @@ -85,6 +86,37 @@ const TranslationView = ({ const verses = useMemo(() => Object.values(apiPageToVersesMap).flat(), [apiPageToVersesMap]); useQcfFont(quranReaderStyles.quranFont, verses); + const { data: pageVersesQuestionsData } = useCountRangeQuestions( + verses?.length > 0 + ? { + from: verses?.[0].verseKey, + to: verses?.[verses.length - 1].verseKey, + } + : null, + ); + + // Accumulate questions data to prevent flickering when new verses are loaded. + // When the verse range changes, SWR fetches new data with undefined initial state. + // By merging new data with existing data, we preserve visibility of the answers button. + const [accumulatedQuestionsData, setAccumulatedQuestionsData] = useState< + Record + >({}); + + // Reset accumulated questions data when the resource context changes + // to avoid leaking stale data across chapters/pages and unbounded growth. + useEffect(() => { + setAccumulatedQuestionsData({}); + }, [resourceId]); + + useEffect(() => { + if (pageVersesQuestionsData) { + setAccumulatedQuestionsData((prev) => ({ + ...prev, + ...pageVersesQuestionsData, + })); + } + }, [pageVersesQuestionsData]); + const itemContentRenderer = (verseIdx: number) => { if (verseIdx === versesCount) { return ( @@ -127,7 +159,7 @@ const TranslationView = ({ /> )} - +
onCopyQuranWords(event, verses, quranReaderStyles.quranFont)} diff --git a/src/components/QuranReader/api.ts b/src/components/QuranReader/api.ts index db5c589767..f3b0047eb6 100644 --- a/src/components/QuranReader/api.ts +++ b/src/components/QuranReader/api.ts @@ -37,6 +37,7 @@ interface ReadingViewRequestKeyInput { locale: string; wordByWordLocale: string; pageVersesRange?: LookupRecord; + selectedTranslations: number[]; } /** @@ -124,21 +125,18 @@ export const getReaderViewRequestKey = ({ reciter, wordByWordLocale, pageVersesRange, + selectedTranslations, }: ReadingViewRequestKeyInput): string => { - return makePageVersesUrl( - pageNumber, - locale, - { - ...getDefaultWordFields(quranReaderStyles.quranFont), - ...getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines), - reciter, - perPage: 'all', - wordTranslationLanguage: wordByWordLocale, - filterPageWords: true, - ...(pageVersesRange && { ...pageVersesRange }), // add the from and to verse range of the current page - }, - false, - ); + return makePageVersesUrl(pageNumber, locale, { + ...getDefaultWordFields(quranReaderStyles.quranFont), + ...getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines), + reciter, + perPage: 'all', + wordTranslationLanguage: wordByWordLocale, + filterPageWords: true, + translations: selectedTranslations.join(','), + ...(pageVersesRange && { ...pageVersesRange }), // add the from and to verse range of the current page + }); }; export const getPagesLookupParams = ( diff --git a/src/components/QuranReader/hooks/useSyncChapterPage.ts b/src/components/QuranReader/hooks/useSyncChapterPage.ts new file mode 100644 index 0000000000..45e17b51c2 --- /dev/null +++ b/src/components/QuranReader/hooks/useSyncChapterPage.ts @@ -0,0 +1,50 @@ +import { useContext } from 'react'; + +import { useDispatch } from 'react-redux'; + +import DataContext from '@/contexts/DataContext'; +import useBrowserLayoutEffect from '@/hooks/useBrowserLayoutEffect'; +import { setLastReadVerse } from '@/redux/slices/QuranReader/readingTracker'; +import { VersesResponse } from 'types/ApiResponses'; + +/** + * A hook that sets the initial page state when navigating to any content type + * (Surah, Verse, Juz, Page, Hizb, Rub, Range). + * + * Uses initialData.verses[0] directly which contains all needed data: + * - verseKey, chapterId, pageNumber, hizbNumber + * + * This works for ALL navigation scenarios (49 combinations × 2 modes = 98 total) + * and updates IMMEDIATELY on navigation (no scrolling required). + * + * Uses useBrowserLayoutEffect to ensure state is set synchronously before paint, + * so the correct page number is displayed immediately. + * + * @param {VersesResponse} initialData - The initial verses data from the page + */ +const useSyncChapterPage = (initialData: VersesResponse): void => { + const dispatch = useDispatch(); + const chaptersData = useContext(DataContext); + + const firstVerse = initialData?.verses?.[0]; + // Use verseKey as the dependency to detect navigation changes + const firstVerseKey = firstVerse?.verseKey; + + useBrowserLayoutEffect(() => { + if (!firstVerse) return; + + dispatch( + setLastReadVerse({ + lastReadVerse: { + verseKey: firstVerse.verseKey, + chapterId: String(firstVerse.chapterId), + page: String(firstVerse.pageNumber), + hizb: String(firstVerse.hizbNumber), + }, + chaptersData, + }), + ); + }, [firstVerseKey, chaptersData, dispatch, firstVerse]); +}; + +export default useSyncChapterPage; diff --git a/src/components/QuranReader/hooks/useSyncReadingProgress.ts b/src/components/QuranReader/hooks/useSyncReadingProgress.ts index 6560fa12b7..b4220af6b0 100644 --- a/src/components/QuranReader/hooks/useSyncReadingProgress.ts +++ b/src/components/QuranReader/hooks/useSyncReadingProgress.ts @@ -94,6 +94,11 @@ const useSyncReadingProgress = ({ isReadingPreference }: UseSyncReadingProgressP (element: Element) => { const lastReadVerse = getObservedVersePayload(element); + // Guard against elements without proper data attributes + if (!lastReadVerse.verseKey) { + return; + } + dispatch( setLastReadVerse({ lastReadVerse, diff --git a/src/components/QuranReader/index.tsx b/src/components/QuranReader/index.tsx index 80387d3ff4..cc5238e0bb 100644 --- a/src/components/QuranReader/index.tsx +++ b/src/components/QuranReader/index.tsx @@ -1,6 +1,3 @@ -/* eslint-disable react/no-multi-comp */ -import React from 'react'; - import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; import { shallowEqual, useSelector } from 'react-redux'; @@ -8,10 +5,12 @@ import { shallowEqual, useSelector } from 'react-redux'; import ContextMenu from './ContextMenu'; import { VerseTrackerContextProvider } from './contexts/VerseTrackerContext'; import DebuggingObserverWindow from './DebuggingObserverWindow'; +import useSyncChapterPage from './hooks/useSyncChapterPage'; import Notes from './Notes/Notes'; import styles from './QuranReader.module.scss'; import QuranReaderView from './QuranReaderView'; +import { SurahInfoModalProvider } from '@/components/chapters/ChapterHeader/components/SurahInfoModalContext'; import FontPreLoader from '@/components/Fonts/FontPreLoader'; import { selectNotes } from '@/redux/slices/QuranReader/notes'; import { selectReadingPreference } from '@/redux/slices/QuranReader/readingPreferences'; @@ -38,8 +37,10 @@ const QuranReader = ({ const readingPreference = useSelector(selectReadingPreference) as ReadingPreference; const isReadingPreference = readingPreference === ReadingPreference.Reading; + useSyncChapterPage(initialData); + return ( - <> + @@ -66,7 +67,7 @@ const QuranReader = ({
- + ); }; diff --git a/src/components/QuranicCalendar/QuranicCalendarHero/ActionButtons.tsx b/src/components/QuranicCalendar/QuranicCalendarHero/ActionButtons.tsx index 2d93a87a2a..83c211d3c6 100644 --- a/src/components/QuranicCalendar/QuranicCalendarHero/ActionButtons.tsx +++ b/src/components/QuranicCalendar/QuranicCalendarHero/ActionButtons.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; import styles from './QuranicCalendarHero.module.scss'; @@ -7,20 +8,65 @@ import styles from './QuranicCalendarHero.module.scss'; import Button, { ButtonShape, ButtonType } from '@/dls/Button/Button'; import IconContainer, { IconSize } from '@/dls/IconContainer/IconContainer'; import Spinner from '@/dls/Spinner/Spinner'; +import AskQuestionIcon from '@/icons/ask-question.svg'; import EmailIcon from '@/icons/email.svg'; import TelegramIcon from '@/icons/telegram.svg'; import TickIcon from '@/icons/tick.svg'; import WhatsappIcon from '@/icons/whatsapp.svg'; import { logButtonClick } from '@/utils/eventLogger'; -import { isMobile } from '@/utils/responsive'; +import { + ASK_QUESTION_FORM_URL, + TELEGRAM_CHANNEL_URL, + WHATSAPP_CHANNEL_URL, +} from '@/utils/externalLinks'; interface ActionButtonsProps { isSubscribed: boolean; isSubscriptionLoading: boolean; isEnrolling: boolean; onEnrollButtonClicked: () => void; + isLoggedIn?: boolean; } +enum SocialButtonName { + Whatsapp = 'whatsapp', + Telegram = 'telegram', +} + +interface SocialButton { + name: SocialButtonName; + icon: React.ComponentType; + url: string; + eventKey: string; +} + +const SUCCESS_PILL_BUTTON_PROPS = { + type: ButtonType.Success, + shape: ButtonShape.Pill, +}; + +const ICON_CONTAINER_PROPS = { + className: styles.iconContainer, + size: IconSize.Small, + shouldFlipOnRTL: true, + shouldForceSetColors: false, +}; + +const SOCIAL_BUTTONS: SocialButton[] = [ + { + name: SocialButtonName.Whatsapp, + icon: WhatsappIcon, + url: WHATSAPP_CHANNEL_URL, + eventKey: 'quranic_calendar_join_whatsapp', + }, + { + name: SocialButtonName.Telegram, + icon: TelegramIcon, + url: TELEGRAM_CHANNEL_URL, + eventKey: 'quranic_calendar_join_telegram', + }, +]; + const subscribeButtonIcon = (isLoading: boolean, isSubscribed: boolean) => { if (isLoading) { return ; @@ -36,77 +82,67 @@ const ActionButtons: React.FC = ({ isSubscriptionLoading, isEnrolling, onEnrollButtonClicked, + isLoggedIn = false, }) => { const { t } = useTranslation('quranic-calendar'); - const onJoinWhatsapp = () => { - logButtonClick('quranic_calendar_join_whatsapp'); - }; - - const onJoinTelegram = () => { - logButtonClick('quranic_calendar_join_telegram'); - }; - - const isMobileBrowser = isMobile(); - - const socialButtons = ( - <> - - - - ); + const showAskQuestionButton = isLoggedIn && isSubscribed; return (
- - {isMobile() ? ( -
{socialButtons}
- ) : ( - socialButtons - )} +
+ + + {showAskQuestionButton && ( + + )} +
+ +
+ {SOCIAL_BUTTONS.map(({ name, icon: Icon, url, eventKey }) => ( + + ))} +
); }; diff --git a/src/components/QuranicCalendar/QuranicCalendarHero/QuranicCalendarHero.module.scss b/src/components/QuranicCalendar/QuranicCalendarHero/QuranicCalendarHero.module.scss index 785ef49814..76e1d4150e 100644 --- a/src/components/QuranicCalendar/QuranicCalendarHero/QuranicCalendarHero.module.scss +++ b/src/components/QuranicCalendar/QuranicCalendarHero/QuranicCalendarHero.module.scss @@ -1,174 +1,257 @@ -@use "src/styles/breakpoints"; -@use "src/styles/theme"; +@use 'src/styles/breakpoints'; +@use 'src/styles/theme'; .container { - position: relative; - width: 100%; + position: relative; + inline-size: 100%; } .backgroundImage { - position: absolute; - inset: 0; - z-index: -1; - background-color: var(--color-search-background); - overflow: hidden; - - svg { - width: 100%; - height: auto; - object-fit: cover; - object-position: center top; - transform: translateY(-40%); - } + position: absolute; + inset: 0; + z-index: -1; + background-color: var(--color-search-background); + overflow: hidden; + + svg { + inline-size: 100%; + block-size: auto; + object-fit: cover; + object-position: center top; + transform: translateY(-40%); + } } .content { - max-width: 1230px; - margin: 0 auto; - display: flex; - justify-content: space-between; - align-items: center; - padding-block: var(--spacing-medium-px); + --max-width: 1230px; + --padding: var(--spacing-large-px); - @include breakpoints.smallerThanTablet { - flex-direction: column; - gap: var(--spacing-medium); - padding-inline: var(--spacing-large-px); - } + margin: 0 auto; + inline-size: 100%; + max-inline-size: min(var(--max-width), calc(100% - (var(--padding) * 2))); + + display: flex; + justify-content: space-between; + align-items: center; + padding-block: var(--spacing-medium-px); + gap: var(--spacing-mega); + + @include breakpoints.smallerThanTablet { + flex-direction: column; + gap: var(--spacing-medium); + + --padding: var(--spacing-large-px); + } } .textContent { - flex: 1; - max-width: 875px; - width: 100%; + flex: 1; + max-inline-size: 875px; + inline-size: 100%; } .title { - font-family: "PlayfairDisplay"; - font-size: calc(3 * var(--font-size-xxlarge-px)); - @include breakpoints.smallerThanTablet { - padding-block-start: var(--spacing-large-px); - font-size: var(--font-size-xjumbo-px); - } - font-weight: var(--font-weight-extra-bold); - color: var(--color-text-default-new); - margin-bottom: var(--spacing-small); + font-family: 'PlayfairDisplay'; + font-size: calc(3 * var(--font-size-xxlarge-px)); + @include breakpoints.smallerThanTablet { + padding-block-start: var(--spacing-large-px); + font-size: var(--font-size-xjumbo-px); + } + font-weight: var(--font-weight-extra-bold); + color: var(--color-text-default-new); + margin-block-end: var(--spacing-small); } .description { - font-size: var(--font-size-large-px); - color: var(--color-text-default-new); - margin-bottom: var(--spacing-medium); - @include breakpoints.smallerThanTablet { - font-size: var(--font-size-small-px); - } + font-size: var(--font-size-large-px); + color: var(--color-text-default-new); + margin-block-end: var(--spacing-medium); + @include breakpoints.smallerThanTablet { + font-size: var(--font-size-small-px); + } } .buttonContainer { - display: flex; - flex-wrap: wrap; + display: flex; + flex-wrap: wrap; + align-items: center; + + column-gap: var(--spacing-medium3-px); + row-gap: var(--spacing-medium-px); + + @include breakpoints.smallerThanTablet { + flex-direction: column; + inline-size: 100%; + + column-gap: var(--spacing-small-px); + row-gap: var(--spacing-small-px); + } +} + +.subscribeButtonsGroup { + display: flex; + gap: var(--spacing-medium3-px); + align-items: center; + + @include breakpoints.smallerThanTablet { + inline-size: 100%; + gap: var(--spacing-small-px); + + a { + flex: 1; + + .askQuestionButton { + inline-size: 100%; + } + } + } +} + +.socialButtonsGroup { + display: flex; + align-items: center; + + column-gap: var(--spacing-medium3-px); + row-gap: var(--spacing-medium-px); + + @include breakpoints.smallerThanTablet { + inline-size: 100%; + flex-wrap: nowrap; justify-content: space-between; - align-items: center; - @include breakpoints.smallerThanTablet { - flex-direction: column; - width: 100%; + column-gap: var(--spacing-small-px); + row-gap: var(--spacing-small-px); + + & > * { + flex: 1; + inline-size: 100%; + } + + .button { + inline-size: 100%; } + } } .weekDisplay { - background-color: var(--color-blue-buttons-and-icons); - border-radius: var(--border-radius-circle-small); - padding-block-start: var(--spacing-medium2-px); - color: var(--color-text-white); - display: flex; - flex-direction: column; - box-shadow: 0px 4px 25px 0px rgba(0, 0, 0, 0.2); + background-color: var(--color-blue-buttons-and-icons); + border-radius: var(--border-radius-circle-small); + padding-block-start: var(--spacing-medium2-px); + color: var(--color-text-white); + display: flex; + flex-direction: column; + box-shadow: 0px 4px 25px 0px rgba(0, 0, 0, 0.2); } .dateInfo { - text-align: center; - display: flex; - flex-direction: column; - padding-inline: var(--spacing-medium2-px); - padding-block-end: var(--spacing-medium2-px); + text-align: center; + display: flex; + flex-direction: column; + padding-inline: var(--spacing-medium2-px); + padding-block-end: var(--spacing-medium2-px); } .hijriDate { - display: flex; - justify-content: center; - align-items: center; - gap: var(--spacing-small-px); - text-align: center; - font-size: var(--font-size-large-px); - font-weight: var(--font-weight-medium); - @include breakpoints.smallerThanTablet { - font-size: var(--font-size-small-px); - } + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-small-px); + text-align: center; + font-size: var(--font-size-large-px); + font-weight: var(--font-weight-medium); + @include breakpoints.smallerThanTablet { + font-size: var(--font-size-small-px); + } } .weekContainer { - display: flex; - padding: var(--spacing-medium2-px) var(--spacing-xxlarge-px) var(--spacing-xxlarge-px) var(--spacing-xxlarge-px); - flex-direction: column; - justify-content: center; - align-items: center; - gap: var(--spacing-medium-px); - align-self: stretch; - border-radius: var(--spacing-large-px); - background: var(--color-background-elevated-new); - @include breakpoints.smallerThanTablet { - padding: var(--spacing-medium-px) var(--spacing-xxlarge-px); - } + display: flex; + padding: var(--spacing-medium2-px) var(--spacing-xxlarge-px) var(--spacing-xxlarge-px) + var(--spacing-xxlarge-px); + flex-direction: column; + justify-content: center; + align-items: center; + gap: var(--spacing-medium-px); + align-self: stretch; + border-radius: var(--spacing-large-px); + background: var(--color-background-elevated-new); + @include breakpoints.smallerThanTablet { + padding: var(--spacing-medium-px) var(--spacing-xxlarge-px); + } } .weekLabel { - color: var(--color-text-faded-new); - font-size: var(--font-size-xxlarge-px); - font-weight: var(--font-weight-semibold); - @include breakpoints.smallerThanTablet { - font-size: var(--font-size-large-px); - } + color: var(--color-text-faded-new); + font-size: var(--font-size-xxlarge-px); + font-weight: var(--font-weight-semibold); + @include breakpoints.smallerThanTablet { + font-size: var(--font-size-large-px); + } } .weekNumber { - font-size: calc(3 * var(--font-size-jumbo-px)); - font-weight: var(--font-weight-bold); - color: var(--color-text-default-new); - display: flex; - justify-content: center; - @include breakpoints.smallerThanTablet { - font-size: var(--font-size-xjumbo-px); - } + font-size: calc(3 * var(--font-size-jumbo-px)); + font-weight: var(--font-weight-bold); + color: var(--color-text-default-new); + display: flex; + justify-content: center; + @include breakpoints.smallerThanTablet { + font-size: var(--font-size-xjumbo-px); + } } .button { - background: var(--color-success-faded); - color: var(--color-text-link-new); - border: none; - padding: var(--spacing-medium-px) var(--spacing-xxlarge-px); + background: var(--color-success-faded); + color: var(--color-text-link-new); + border: none; + padding: var(--spacing-medium-px) var(--spacing-medium2-px); - @include breakpoints.smallerThanTablet { - flex: 1; - padding: var(--spacing-medium-px) var(--spacing-medium2-px); - text-align: center; - } + @include breakpoints.smallerThanTablet { + flex: 1; + text-align: center; + } } .iconContainer { - margin-inline-end: var(--spacing-small-px); + margin-inline-end: var(--spacing-small-px); } -.socialButtonsContainer { - display: flex; - gap: var(--spacing-medium); - flex-wrap: nowrap; - justify-content: space-between; - align-items: center; - width: 100%; - margin-top: var(--spacing-medium); +.subscribeButton { + @include breakpoints.smallerThanTablet { + inline-size: 100%; + flex: 1; + } } -.subscribeButton { - width: 100%; +.subscribeButtonSmall { + @include breakpoints.smallerThanTablet { + flex: 0; + } + + @include breakpoints.smallerThanMobileL { + .iconContainer { + margin-inline-end: 0; + } + } +} + +.subscribeText { + display: inline; + @include breakpoints.smallerThanMobileL { + display: none; + } +} + +.joinSocialText { + display: inline; + @include breakpoints.smallerThanDesktop { + display: none; + } +} + +.joinSocialMobileText { + display: none; + @include breakpoints.smallerThanDesktop { + display: inline; + } } diff --git a/src/components/QuranicCalendar/QuranicCalendarHero/index.tsx b/src/components/QuranicCalendar/QuranicCalendarHero/index.tsx index 930500a094..547983ac23 100644 --- a/src/components/QuranicCalendar/QuranicCalendarHero/index.tsx +++ b/src/components/QuranicCalendar/QuranicCalendarHero/index.tsx @@ -9,10 +9,10 @@ import styles from './QuranicCalendarHero.module.scss'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; import useGetUserQuranProgramEnrollment from '@/hooks/auth/useGetUserQuranProgramEnrollment'; +import useIsLoggedIn from '@/hooks/auth/useIsLoggedIn'; import Background from '@/icons/background.svg'; import { enrollUserInQuranProgram } from '@/utils/auth/api'; import { QURANIC_CALENDAR_PROGRAM_ID } from '@/utils/auth/constants'; -import { isLoggedIn } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; import { toLocalizedNumber } from '@/utils/locale'; import { @@ -36,12 +36,13 @@ const QuranicCalendarHero: React.FC = ({ currentQuranicCalendarWeek, curr } = useGetUserQuranProgramEnrollment({ programId: QURANIC_CALENDAR_PROGRAM_ID, }); + const { isLoggedIn } = useIsLoggedIn(); const [isEnrolling, setIsEnrolling] = useState(false); const router = useRouter(); const onEnrollButtonClicked = async () => { logButtonClick('quranic_calendar_enroll_in_program'); - if (isLoggedIn()) { + if (isLoggedIn) { if (isSubscribed) { router.replace(getNotificationSettingsNavigationUrl()); } else if (!isSubscriptionLoading && !isSubscribed && !isEnrolling) { @@ -79,6 +80,7 @@ const QuranicCalendarHero: React.FC = ({ currentQuranicCalendarWeek, curr isSubscriptionLoading={isSubscriptionLoading} isEnrolling={isEnrolling} onEnrollButtonClicked={onEnrollButtonClicked} + isLoggedIn={isLoggedIn} />
diff --git a/src/components/ReadingGoal/UpdateReadingGoalModal/index.tsx b/src/components/ReadingGoal/UpdateReadingGoalModal/index.tsx index 9013eab9f2..8738e0f209 100644 --- a/src/components/ReadingGoal/UpdateReadingGoalModal/index.tsx +++ b/src/components/ReadingGoal/UpdateReadingGoalModal/index.tsx @@ -17,6 +17,7 @@ import Select, { SelectSize } from '@/dls/Forms/Select'; import Modal from '@/dls/Modal/Modal'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; import useGetMushaf from '@/hooks/useGetMushaf'; +import { logErrorToSentry } from '@/lib/sentry'; import { Goal, GoalCategory, @@ -138,7 +139,8 @@ const UpdateReadingGoalModal = ({ isDisabled, goal }: UpdateReadingGoalButtonPro await updateReadingGoalAndClearCache(data); toast(t('edit-goal.success'), { status: ToastStatus.Success }); closeModal(); - } catch { + } catch (e) { + logErrorToSentry(e); toast(t('common:error.general'), { status: ToastStatus.Error }); } }; diff --git a/src/components/ReadingGoalPage/ReadingGoalExamplesTab.tsx b/src/components/ReadingGoalPage/ReadingGoalExamplesTab.tsx index 21fc62f7ec..8458316871 100644 --- a/src/components/ReadingGoalPage/ReadingGoalExamplesTab.tsx +++ b/src/components/ReadingGoalPage/ReadingGoalExamplesTab.tsx @@ -1,6 +1,10 @@ import useTranslation from 'next-translate/useTranslation'; -import { readingGoalExamples, ReadingGoalTabProps } from './hooks/useReadingGoalReducer'; +import { + readingGoalExamples, + ReadingGoalExampleKey, + ReadingGoalTabProps, +} from './hooks/useReadingGoalReducer'; import OptionButton from './OptionButton'; import styles from './ReadingGoalPage.module.scss'; @@ -19,7 +23,7 @@ const ReadingGoalExamplesTab: React.FC = ({

{t('examples-subtitle')}

- {Object.keys(readingGoalExamples).map((exampleKey: keyof typeof readingGoalExamples) => { + {Object.values(ReadingGoalExampleKey).map((exampleKey) => { const example = readingGoalExamples[exampleKey]; return ( diff --git a/src/components/ReadingGoalPage/hooks/useReadingGoalReducer.ts b/src/components/ReadingGoalPage/hooks/useReadingGoalReducer.ts index 4015c4fbe6..49d429f6f1 100644 --- a/src/components/ReadingGoalPage/hooks/useReadingGoalReducer.ts +++ b/src/components/ReadingGoalPage/hooks/useReadingGoalReducer.ts @@ -8,12 +8,23 @@ import ClockIcon from '@/icons/clock.svg'; import SettingsIcon from '@/icons/settings-stroke.svg'; import { QuranGoalPeriod, GoalType } from '@/types/auth/Goal'; +export enum ReadingGoalExampleKey { + TEN_MINS = '10_mins', + KHATM = 'khatm', + YEARLY = 'yearly', + CUSTOM = 'custom', +} + +export function isReadingGoalExampleKey(key: string): key is ReadingGoalExampleKey { + return Object.values(ReadingGoalExampleKey).includes(key as ReadingGoalExampleKey); +} + interface ReadingGoalState { period: QuranGoalPeriod; type: GoalType; pages: number; seconds: number; - exampleKey: keyof typeof readingGoalExamples | null; + exampleKey: ReadingGoalExampleKey | null; duration: number | null; rangeStartVerse: string | null; rangeEndVerse: string | null; @@ -123,7 +134,7 @@ const reducer = (state: ReadingGoalState, action: ReadingGoalAction): ReadingGoa }; export const readingGoalExamples = { - '10_mins': { + [ReadingGoalExampleKey.TEN_MINS]: { i18nKey: 'time', icon: ClockIcon, recommended: true, @@ -133,7 +144,7 @@ export const readingGoalExamples = { period: QuranGoalPeriod.Daily, }, }, - khatm: { + [ReadingGoalExampleKey.KHATM]: { i18nKey: 'khatm', icon: BookIcon, values: { @@ -144,7 +155,7 @@ export const readingGoalExamples = { period: QuranGoalPeriod.Continuous, }, }, - yearly: { + [ReadingGoalExampleKey.YEARLY]: { i18nKey: 'year', icon: CalendarIcon, values: { @@ -155,7 +166,7 @@ export const readingGoalExamples = { period: QuranGoalPeriod.Continuous, }, }, - custom: { + [ReadingGoalExampleKey.CUSTOM]: { i18nKey: 'custom', icon: SettingsIcon, }, @@ -172,8 +183,18 @@ const initialState: ReadingGoalState = { rangeEndVerse: '114:6', }; -const useReadingGoalReducer = () => { - const [state, dispatch] = useReducer(reducer, initialState); +const initFromExample = (key?: ReadingGoalExampleKey): ReadingGoalState => { + if (!key) return initialState; + if (key === ReadingGoalExampleKey.CUSTOM) return { ...initialState, exampleKey: key }; + return { + ...initialState, + exampleKey: key, + ...readingGoalExamples[key].values, + }; +}; + +const useReadingGoalReducer = (initialExampleKey?: ReadingGoalExampleKey) => { + const [state, dispatch] = useReducer(reducer, initialExampleKey, initFromExample); return [state, dispatch] as const; }; diff --git a/src/components/ReadingGoalPage/index.tsx b/src/components/ReadingGoalPage/index.tsx index e7a5674740..667d40d512 100644 --- a/src/components/ReadingGoalPage/index.tsx +++ b/src/components/ReadingGoalPage/index.tsx @@ -7,7 +7,10 @@ import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; import { useSWRConfig } from 'swr'; -import useReadingGoalReducer, { ReadingGoalTabProps } from './hooks/useReadingGoalReducer'; +import useReadingGoalReducer, { + ReadingGoalExampleKey, + ReadingGoalTabProps, +} from './hooks/useReadingGoalReducer'; import styles from './ReadingGoalPage.module.scss'; import { logTabClick, logTabInputChange, logTabNextClick, TabKey, tabsArray } from './utils/tabs'; import { validateReadingGoalData } from './utils/validator'; @@ -24,17 +27,30 @@ import layoutStyle from '@/pages/index.module.scss'; import { CreateGoalRequest, GoalCategory, GoalType, QuranGoalPeriod } from '@/types/auth/Goal'; import { addReadingGoal } from '@/utils/auth/api'; import { makeStreakUrl } from '@/utils/auth/apiPaths'; +import { isLoggedIn } from '@/utils/auth/login'; import { logFormSubmission } from '@/utils/eventLogger'; +import { getLoginNavigationUrl, getReadingGoalNavigationUrl } from '@/utils/navigation'; -const ReadingGoalOnboarding: React.FC = () => { +interface Props { + initialExampleKey: ReadingGoalExampleKey | null; +} + +const ReadingGoalOnboarding: React.FC = ({ initialExampleKey }) => { const { t } = useTranslation('reading-goal'); const router = useRouter(); const chaptersData = useContext(DataContext); const mushaf = useGetMushaf(); + let initialTabIdx = 0; + if (initialExampleKey) { + // if user select example then skip preview + // otherwise go to next tab index + initialTabIdx = initialExampleKey === ReadingGoalExampleKey.CUSTOM ? 1 : tabsArray.length - 1; + } + const [loading, setLoading] = useState(false); - const [tabIdx, setTabIdx] = useState(0); - const [state, dispatch] = useReadingGoalReducer(); + const [tabIdx, setTabIdx] = useState(initialTabIdx); + const [state, dispatch] = useReadingGoalReducer(initialExampleKey); const toast = useToast(); const { cache } = useSWRConfig(); @@ -89,7 +105,7 @@ const ReadingGoalOnboarding: React.FC = () => { const percentage = isPreviewTab ? 100 : (tabIdx / tabsArray.length) * 100; const onPrev = () => { - if (tabIdx !== 0 && state.exampleKey !== 'custom') { + if (tabIdx !== 0 && state.exampleKey !== ReadingGoalExampleKey.CUSTOM) { setTabIdx(0); logTabClick(Tab.key, 'previous'); } else { @@ -100,15 +116,25 @@ const ReadingGoalOnboarding: React.FC = () => { const onNext = () => { if (!isPreviewTab) { - if (tabIdx === 0 && state.exampleKey !== 'custom') { + let nextTabIdx = 0; + if (tabIdx === 0 && state.exampleKey !== ReadingGoalExampleKey.CUSTOM) { // if the user selected an example, skip to the preview tab - setTabIdx(tabsArray.length - 1); + nextTabIdx = tabsArray.length - 1; } else { // otherwise, go to the next tab - setTabIdx((prevIdx) => prevIdx + 1); + nextTabIdx = tabIdx + 1; } logTabNextClick(Tab.key, state); + + if (!isLoggedIn()) { + router.push( + getLoginNavigationUrl(getReadingGoalNavigationUrl(state.exampleKey ?? undefined)), + ); + return; + } + + setTabIdx(nextTabIdx); } else { onSubmit(); } diff --git a/src/components/Sanity/utils.ts b/src/components/Sanity/utils.ts new file mode 100644 index 0000000000..baa6168f06 --- /dev/null +++ b/src/components/Sanity/utils.ts @@ -0,0 +1,28 @@ +import { fetcher } from '@/api'; +import { executeGroqQuery } from '@/lib/sanity'; +import { Course } from '@/types/auth/Course'; +import { makeGetCourseUrl } from '@/utils/auth/apiPaths'; + +export const PRODUCT_UPDATES_QUERY = + '*[_type == "productUpdate"]| order(date desc){ title, slug, mainPhoto, date, summary }'; + +export const SINGLE_PRODUCT_UPDATE_QUERY = + '*[_type == "productUpdate" && slug.current == $slug][0]'; + +export const getCourseBySlug = async (slug: string): Promise => { + return fetcher(makeGetCourseUrl(slug)); +}; + +export const getProductUpdatesPage = async () => { + return executeGroqQuery(PRODUCT_UPDATES_QUERY); +}; + +export const getSingleProductUpdatePage = async (slug: string) => { + return executeGroqQuery( + SINGLE_PRODUCT_UPDATE_QUERY, + { + slug, + }, + true, + ); +}; diff --git a/src/components/Search/PreInput/index.tsx b/src/components/Search/PreInput/index.tsx index 2b457ea2b4..c3cac3d0d3 100644 --- a/src/components/Search/PreInput/index.tsx +++ b/src/components/Search/PreInput/index.tsx @@ -58,7 +58,7 @@ const PreInput: React.FC = ({ onSearchKeywordClicked, source }) => {
-
+
{Object.keys(POPULAR_SEARCH_QUERIES).map((popularSearchQuery) => { const chapterId = POPULAR_SEARCH_QUERIES[popularSearchQuery]; const url = getSurahNavigationUrl(POPULAR_SEARCH_QUERIES[popularSearchQuery]); diff --git a/src/components/Search/SearchBodyContainer.tsx b/src/components/Search/SearchBodyContainer.tsx index f02902a1c3..65486df7fc 100644 --- a/src/components/Search/SearchBodyContainer.tsx +++ b/src/components/Search/SearchBodyContainer.tsx @@ -46,6 +46,7 @@ const SearchBodyContainer: React.FC = ({ !searchQuery || isSearching || hasError || (!isSearching && !hasError && isEmptyResponse); return (
= ({
{isExpanded && ( -
+
)} diff --git a/src/components/Search/SearchResults/SearchResultsHeader/index.tsx b/src/components/Search/SearchResults/SearchResultsHeader/index.tsx index f13e8dd3dc..777ab272a9 100644 --- a/src/components/Search/SearchResults/SearchResultsHeader/index.tsx +++ b/src/components/Search/SearchResults/SearchResultsHeader/index.tsx @@ -43,7 +43,9 @@ const SearchResultsHeader: React.FC = ({ searchQuery, onSearchResultClick onKeyDown={onNavigationLinkClicked} >
-

{t('common:search.more-results')}

+

+ {t('common:search.more-results')} +

} /> diff --git a/src/components/Verse/AdvancedCopy/VerseAdvancedCopy.tsx b/src/components/Verse/AdvancedCopy/VerseAdvancedCopy.tsx index 0133a3b348..47f935aaa2 100644 --- a/src/components/Verse/AdvancedCopy/VerseAdvancedCopy.tsx +++ b/src/components/Verse/AdvancedCopy/VerseAdvancedCopy.tsx @@ -20,6 +20,7 @@ import Link, { LinkVariant } from '@/dls/Link/Link'; import { selectSelectedTranslations } from '@/redux/slices/QuranReader/translations'; import Language from '@/types/Language'; import { QuranFont } from '@/types/QuranReader'; +import { WordVerse } from '@/types/Word'; import { makeTranslationsUrl } from '@/utils/apiPaths'; import { areArraysEqual } from '@/utils/array'; import { throwIfError } from '@/utils/error'; @@ -33,10 +34,9 @@ import { toLocalizedVerseKey } from '@/utils/locale'; import { generateChapterVersesKeys } from '@/utils/verse'; import { getAvailableTranslations } from 'src/api'; import DataContext from 'src/contexts/DataContext'; -import Verse from 'types/Verse'; interface Props { - verse: Verse; + verse: WordVerse; children({ onCopy, actionText, ayahSelectionComponent, loading }): React.ReactElement; } const RESET_BUTTON_TIMEOUT_MS = 5 * 1000; diff --git a/src/components/Verse/AdvancedCopy/utils/getTextCopy.ts b/src/components/Verse/AdvancedCopy/utils/getTextCopy.ts index 7a730ab0d4..c91d78e915 100644 --- a/src/components/Verse/AdvancedCopy/utils/getTextCopy.ts +++ b/src/components/Verse/AdvancedCopy/utils/getTextCopy.ts @@ -21,6 +21,7 @@ import { getAdvancedCopyRawResult } from 'src/api'; * @param {object} options.chaptersData - The chapters data object * @returns {Promise} textToCopy */ +// eslint-disable-next-line react-func/max-lines-per-function const getTextToCopy = ({ verseKey, showRangeOfVerses, @@ -71,9 +72,11 @@ const getTextToCopy = ({ // Get the result and format the final text return getAdvancedCopyRawResult(apiOptions).then((res) => { - const text = showRangeOfVerses ? res.result : res.result.split('\n').slice(2).join('\n'); // Remove the first 2 lines which contain the verse key + const text = showRangeOfVerses ? res.result : res.result.split('\n').slice(2).join('\n'); - return `${surahInfoString}\n\n${text}${verseUrl}`; + // construct the final complete text for the clipboard + // remove trailing newlines before the url + return `${surahInfoString}\n\n${text.replace(/\n+$/, '')}\n\n${verseUrl}`; }); }; diff --git a/src/components/Verse/BookmarkAction.tsx b/src/components/Verse/BookmarkAction.tsx index 4025808bc7..c3928342cf 100644 --- a/src/components/Verse/BookmarkAction.tsx +++ b/src/components/Verse/BookmarkAction.tsx @@ -1,166 +1,115 @@ -/* eslint-disable max-lines */ -/* eslint-disable react-func/max-lines-per-function */ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; +import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; -import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import { useSWRConfig } from 'swr'; -import useSWRImmutable from 'swr/immutable'; +import { shallowEqual, useSelector } from 'react-redux'; -import PopoverMenu from '../dls/PopoverMenu/PopoverMenu'; +import styles from '../QuranReader/TranslationView/TranslationViewCell.module.scss'; +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import useIsMobile from '@/hooks/useIsMobile'; +import useVerseBookmark from '@/hooks/useVerseBookmark'; import BookmarkedIcon from '@/icons/bookmark.svg'; import UnBookmarkedIcon from '@/icons/unbookmarked.svg'; -import Spinner from 'src/components/dls/Spinner/Spinner'; -import { ToastStatus, useToast } from 'src/components/dls/Toast/Toast'; -import { selectBookmarks, toggleVerseBookmark } from 'src/redux/slices/QuranReader/bookmarks'; -import { selectQuranReaderStyles } from 'src/redux/slices/QuranReader/styles'; -import { getMushafId } from 'src/utils/api'; -import { addBookmark, deleteBookmarkById, getBookmark } from 'src/utils/auth/api'; -import { makeBookmarksUrl, makeBookmarkUrl } from 'src/utils/auth/apiPaths'; -import { isLoggedIn } from 'src/utils/auth/login'; -import { logButtonClick } from 'src/utils/eventLogger'; -import BookmarkType from 'types/BookmarkType'; - -const BookmarkAction = ({ verse, isTranslationView, onActionTriggered, bookmarksRangeUrl }) => { - const bookmarkedVerses = useSelector(selectBookmarks, shallowEqual); +import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; +import { WordVerse } from '@/types/Word'; +import { getMushafId } from '@/utils/api'; +import { logButtonClick } from '@/utils/eventLogger'; + +interface Props { + verse: WordVerse; + isTranslationView: boolean; + onActionTriggered?: () => void; + bookmarksRangeUrl?: string; +} + +const BookmarkAction: React.FC = ({ + verse, + isTranslationView, + onActionTriggered, + bookmarksRangeUrl, +}) => { const quranReaderStyles = useSelector(selectQuranReaderStyles, shallowEqual); const mushafId = getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf; const { t } = useTranslation('common'); - const dispatch = useDispatch(); + const isMobile = useIsMobile(); + + // Use custom hook for all bookmark logic + const { isVerseBookmarked, handleToggleBookmark } = useVerseBookmark({ + verse, + mushafId, + bookmarksRangeUrl, + }); + + // Helper: Get event name for analytics + const getEventName = useCallback(() => { + const view = isTranslationView ? 'translation_view' : 'reading_view'; + const action = isVerseBookmarked ? 'un_bookmark' : 'bookmark'; + return `${view}_verse_actions_menu_${action}`; + }, [isTranslationView, isVerseBookmarked]); + + const onToggleBookmarkClicked = useCallback( + (e?: React.MouseEvent) => { + // Prevent default to avoid page scroll + if (e) { + e.preventDefault(); + e.stopPropagation(); + } - const toast = useToast(); - const { cache, mutate: globalMutate } = useSWRConfig(); + // eslint-disable-next-line i18next/no-literal-string + logButtonClick(getEventName()); - const { - data: bookmark, - isValidating: isVerseBookmarkedLoading, - mutate, - } = useSWRImmutable( - isLoggedIn() - ? makeBookmarkUrl( - mushafId, - Number(verse.chapterId), - BookmarkType.Ayah, - Number(verse.verseNumber), - ) - : null, - async () => { - const response = await getBookmark( - mushafId, - Number(verse.chapterId), - BookmarkType.Ayah, - Number(verse.verseNumber), - ); - return response; + handleToggleBookmark(); + onActionTriggered?.(); }, + [getEventName, handleToggleBookmark, onActionTriggered], ); - const isVerseBookmarked = useMemo(() => { - const isUserLoggedIn = isLoggedIn(); - if (isUserLoggedIn && bookmark) { - return bookmark; - } - if (!isUserLoggedIn) { - return !!bookmarkedVerses[verse.verseKey]; - } - return false; - }, [bookmarkedVerses, bookmark, verse.verseKey]); - const updateInBookmarkRange = (value) => { - // when it's translation view, we need to invalidate the cached bookmarks range - if (bookmarksRangeUrl) { - const bookmarkedVersesRange = cache.get(bookmarksRangeUrl); - const nextBookmarkedVersesRange = { - ...bookmarkedVersesRange, - [verse.verseKey]: value, - }; - globalMutate(bookmarksRangeUrl, nextBookmarkedVersesRange, { - revalidate: false, - }); + const bookmarkIcon = useMemo(() => { + if (isVerseBookmarked) { + return ; } - }; - - const onToggleBookmarkClicked = () => { - // eslint-disable-next-line i18next/no-literal-string - logButtonClick( - // eslint-disable-next-line i18next/no-literal-string - `${isTranslationView ? 'translation_view' : 'reading_view'}_verse_actions_menu_${ - isVerseBookmarked ? 'un_bookmark' : 'bookmark' - }`, + return ( + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + ); + }, [isVerseBookmarked]); + + // For use in the TopActions component (standalone button) + if (isTranslationView || (!isTranslationView && isMobile)) { + return ( + ); - - if (isLoggedIn()) { - // optimistic update, we are making assumption that the bookmark update will succeed - - if (isVerseBookmarked) { - mutate(() => null, { - revalidate: false, - }); - } - - cache.delete( - makeBookmarksUrl( - getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf, - ), - ); - - if (!isVerseBookmarked) { - addBookmark({ - key: Number(verse.chapterId), - mushafId, - type: BookmarkType.Ayah, - verseNumber: verse.verseNumber, - }) - .then((newBookmark) => { - mutate(); - updateInBookmarkRange(newBookmark); - toast(t('verse-bookmarked'), { - status: ToastStatus.Success, - }); - }) - .catch((err) => { - if (err.status === 400) { - toast(t('common:error.bookmark-sync'), { - status: ToastStatus.Error, - }); - return; - } - toast(t('error.general'), { - status: ToastStatus.Error, - }); - }); - } else { - deleteBookmarkById(bookmark.id).then(() => { - updateInBookmarkRange(null); - toast(t('verse-bookmark-removed'), { - status: ToastStatus.Success, - }); - }); - } - } else { - dispatch(toggleVerseBookmark(verse.verseKey)); - } - - if (onActionTriggered) { - onActionTriggered(); - } - }; - - let bookmarkIcon = ; - if (!isVerseBookmarkedLoading) { - bookmarkIcon = isVerseBookmarked ? : ; } + // For use in the overflow menu Reading Mode Desktop (PopoverMenu.Item) return ( - <> - - {isVerseBookmarked ? `${t('bookmarked')}!` : `${t('bookmark')}`} - - + + {isVerseBookmarked ? `${t('bookmarked')}!` : `${t('bookmark')}`} + ); }; diff --git a/src/components/Verse/Notes/NotesAction/index.tsx b/src/components/Verse/Notes/NotesAction/index.tsx index 680183ed15..defbb34367 100644 --- a/src/components/Verse/Notes/NotesAction/index.tsx +++ b/src/components/Verse/Notes/NotesAction/index.tsx @@ -1,54 +1,90 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; import NoteModal from '@/components/Notes/NoteModal'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; import PopoverMenu from '@/dls/PopoverMenu/PopoverMenu'; import useCountRangeNotes from '@/hooks/auth/useCountRangeNotes'; -import EmptyNotesIcon from '@/icons/notes-empty.svg'; +import useSafeTimeout from '@/hooks/useSafeTimeout'; import NotesIcon from '@/icons/notes-filled.svg'; -import Verse from '@/types/Verse'; +import NotesFilledIcon from '@/icons/notes-with-pencil-filled.svg'; +import { logErrorToSentry } from '@/lib/sentry'; +import { WordVerse } from '@/types/Word'; import { isLoggedIn } from '@/utils/auth/login'; -import { logButtonClick } from '@/utils/eventLogger'; +import { logButtonClick, logEvent } from '@/utils/eventLogger'; import { getChapterWithStartingVerseUrl, getLoginNavigationUrl } from '@/utils/navigation'; +import AudioPlayerEventType from '@/xstate/actors/audioPlayer/types/AudioPlayerEventType'; +import { AudioPlayerMachineContext } from '@/xstate/AudioPlayerMachineContext'; type Props = { - verse: Verse; + verse: WordVerse; + onActionTriggered?: () => void; }; -const NotesAction: React.FC = ({ verse }) => { +const CLOSE_POPOVER_AFTER_MS = 150; + +const NotesAction: React.FC = ({ verse, onActionTriggered }) => { const { data: notesCount } = useCountRangeNotes({ from: verse.verseKey, to: verse.verseKey }); const [isModalOpen, setIsModalOpen] = useState(false); const { t } = useTranslation('common'); const router = useRouter(); + const audioPlayerService = useContext(AudioPlayerMachineContext); const onNotesClicked = () => { const isUserLoggedIn = isLoggedIn(); logButtonClick('note_menu_item', { isUserLoggedIn }); if (!isUserLoggedIn) { - router.push(getLoginNavigationUrl(getChapterWithStartingVerseUrl(verse.verseKey))); + audioPlayerService.send({ type: 'CLOSE' } as AudioPlayerEventType); + + try { + router.push(getLoginNavigationUrl(getChapterWithStartingVerseUrl(verse.verseKey))); + } catch (e) { + logErrorToSentry(e); + // If there's an error parsing the verseKey, navigate to chapter 1 + router.push(getLoginNavigationUrl('/1')); + } } else { setIsModalOpen(true); } }; - const onClose = () => { + const setSafeTimeout = useSafeTimeout(); + + const onModalClose = () => { + logEvent('reading_view_notes_modal_close'); setIsModalOpen(false); - }; + if (onActionTriggered) { + setSafeTimeout(() => { + onActionTriggered(); + }, CLOSE_POPOVER_AFTER_MS); + } + }; const hasNotes = notesCount && notesCount[verse.verseKey] > 0; return ( <> : } + icon={ + hasNotes ? ( + + ) : ( + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + ) + } > - {t('notes.title')} + {t('notes.label')} - + ); }; diff --git a/src/components/Verse/Notes/index.tsx b/src/components/Verse/Notes/index.tsx index ef7578cdbd..8967efa9ad 100644 --- a/src/components/Verse/Notes/index.tsx +++ b/src/components/Verse/Notes/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useContext, useState } from 'react'; import classNames from 'classnames'; import { useRouter } from 'next/router'; @@ -7,11 +7,16 @@ import useTranslation from 'next-translate/useTranslation'; import NoteModal from '@/components/Notes/NoteModal'; import styles from '@/components/QuranReader/TranslationView/TranslationViewCell.module.scss'; import Button, { ButtonShape, ButtonSize, ButtonType, ButtonVariant } from '@/dls/Button/Button'; -import EmptyNotesIcon from '@/icons/notes-empty.svg'; -import NotesIcon from '@/icons/notes-filled.svg'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import NotesFilledIcon from '@/icons/notes-with-pencil-filled.svg'; +import NotesIcon from '@/icons/notes-with-pencil.svg'; +import { logErrorToSentry } from '@/lib/sentry'; +import ZIndexVariant from '@/types/enums/ZIndexVariant'; import { isLoggedIn } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; import { getChapterWithStartingVerseUrl, getLoginNavigationUrl } from '@/utils/navigation'; +import AudioPlayerEventType from '@/xstate/actors/audioPlayer/types/AudioPlayerEventType'; +import { AudioPlayerMachineContext } from '@/xstate/AudioPlayerMachineContext'; export enum VerseNotesTrigger { IconButton = 'button', @@ -28,6 +33,7 @@ const VerseNotes = ({ verseKey, isTranslationView, hasNotes }: VerseNotesProps) const [isModalOpen, setIsModalOpen] = useState(false); const { t } = useTranslation('common'); const router = useRouter(); + const audioPlayerService = useContext(AudioPlayerMachineContext); const onItemClicked = () => { const isUserLoggedIn = isLoggedIn(); @@ -35,31 +41,60 @@ const VerseNotes = ({ verseKey, isTranslationView, hasNotes }: VerseNotesProps) isTranslationView, isLoggedIn, }); + if (!isUserLoggedIn) { - router.push(getLoginNavigationUrl(getChapterWithStartingVerseUrl(verseKey))); + audioPlayerService.send({ type: 'CLOSE' } as AudioPlayerEventType); + + try { + router.push(getLoginNavigationUrl(getChapterWithStartingVerseUrl(verseKey))); + } catch (e) { + logErrorToSentry(e); + // If there's an error parsing the verseKey, navigate to chapter 1 + router.push(getLoginNavigationUrl('/1')); + } } else { setIsModalOpen(true); } }; - const onClose = () => { + const onModalClose = () => { setIsModalOpen(false); }; return ( <> - + + ); }; diff --git a/src/components/Verse/OverflowVerseActionsMenu.tsx b/src/components/Verse/OverflowVerseActionsMenu.tsx index d783850309..ecd350595d 100644 --- a/src/components/Verse/OverflowVerseActionsMenu.tsx +++ b/src/components/Verse/OverflowVerseActionsMenu.tsx @@ -10,11 +10,12 @@ import cellStyles from '../QuranReader/TranslationView/TranslationViewCell.modul import styles from './OverflowVerseActionsMenuBody.module.scss'; import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; import PopoverMenu from '@/dls/PopoverMenu/PopoverMenu'; import Spinner from '@/dls/Spinner/Spinner'; import OverflowMenuIcon from '@/icons/menu_more_horiz.svg'; +import { WordVerse } from '@/types/Word'; import { logEvent } from '@/utils/eventLogger'; -import Verse from 'types/Verse'; const OverflowVerseActionsMenuBody = dynamic(() => import('./OverflowVerseActionsMenuBody'), { ssr: false, @@ -22,7 +23,7 @@ const OverflowVerseActionsMenuBody = dynamic(() => import('./OverflowVerseAction }); interface Props { - verse: Verse; + verse: WordVerse; isTranslationView?: boolean; onActionTriggered?: () => void; bookmarksRangeUrl?: string; @@ -35,9 +36,19 @@ const OverflowVerseActionsMenu: React.FC = ({ bookmarksRangeUrl, }) => { const { t } = useTranslation('common'); + + const onOpenModalChange = (open: boolean) => { + logEvent( + `${isTranslationView ? 'translation_view' : 'reading_view'}_verse_actions_menu_${ + open ? 'open' : 'close' + }`, + ); + }; + return (
= ({ className={classNames( cellStyles.iconContainer, cellStyles.verseAction, - { - [cellStyles.fadedVerseAction]: isTranslationView, - }, + { [styles.moreMenuTrigger]: isTranslationView }, 'overflow-verse-actions-menu-trigger', // for onboarding )} + shouldFlipOnRTL={false} ariaLabel={t('more')} > - - + + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> } isModal isPortalled - onOpenChange={(open: boolean) => { - logEvent( - `${isTranslationView ? 'translation_view' : 'reading_view'}_verse_actions_menu_${ - open ? 'open' : 'close' - }`, - ); - }} + onOpenChange={onOpenModalChange} > void; - setSelectedMenu: (selectedMenu: VerseActionsOverflowMenu) => void; + setSelectedMenu: (selectedMenu: VerseActionsMenuType) => void; + hasBackButton?: boolean; }; const ShareVerseActionsMenu: React.FC = ({ @@ -54,6 +50,7 @@ const ShareVerseActionsMenu: React.FC = ({ isTranslationView, onActionTriggered, setSelectedMenu, + hasBackButton = true, }) => { const { t, lang } = useTranslation('common'); const [isCopied, setIsCopied] = useState(false); @@ -66,9 +63,7 @@ const ShareVerseActionsMenu: React.FC = ({ if (isCopied === true) { timeoutId = setTimeout(() => { setIsCopied(false); - if (onActionTriggered) { - onActionTriggered(); - } + onActionTriggered?.(); }, RESET_ACTION_TEXT_TIMEOUT_MS); } return () => { @@ -83,18 +78,21 @@ const ShareVerseActionsMenu: React.FC = ({ () => toast(t('shared'), { status: ToastStatus.Success }), lang, ); - if (onActionTriggered) { - onActionTriggered(); - } + onActionTriggered?.(); }; const onBackClicked = () => { - logButtonClick(`back_verse_actions_menu`); - setSelectedMenu(VerseActionsOverflowMenu.Main); + logButtonClick( + `${isTranslationView ? 'translation_view' : 'reading_view'}_back_verse_actions_menu`, + ); + setSelectedMenu(VerseActionsMenuType.Main); + onActionTriggered?.(); }; const onGenerateClicked = () => { - logButtonClick(`generate_media_verse_action`); + logButtonClick( + `${isTranslationView ? 'translation_view' : 'reading_view'}_generate_media_verse_action`, + ); router.push( getQuranMediaMakerNavigationUrl({ [QueryParam.SURAH]: verse.chapterId as string, @@ -103,25 +101,21 @@ const ShareVerseActionsMenu: React.FC = ({ [QueryParam.PREVIEW_MODE]: PreviewMode.DISABLED, }), ); + onActionTriggered?.(); }; return (
- } onClick={onBackClicked}> - {t('common:share')} - - + {hasBackButton && ( + } onClick={onBackClicked}> + {t('common:share')} + + )} }> {t('quran-reader:cpy-link')} }> {t('quran-reader:generate-media')} - -
); }; diff --git a/src/components/Verse/OverflowVerseActionsMenuBody/index.tsx b/src/components/Verse/OverflowVerseActionsMenuBody/index.tsx index 27cfc6b0bb..c9e1f3ca10 100644 --- a/src/components/Verse/OverflowVerseActionsMenuBody/index.tsx +++ b/src/components/Verse/OverflowVerseActionsMenuBody/index.tsx @@ -1,26 +1,19 @@ import React, { useState } from 'react'; -import useTranslation from 'next-translate/useTranslation'; - -import BookmarkAction from '../BookmarkAction'; -import NotesAction from '../Notes/NotesAction'; import SaveToCollectionAction from '../SaveToCollectionAction'; +import TranslationFeedbackAction from '../TranslationFeedback/TranslationFeedbackAction'; +import VerseActionAdvancedCopy from '../VerseActionAdvancedCopy'; import VerseActionRepeatAudio from '../VerseActionRepeatAudio'; -import styles from './OverflowVerseActionsMenuBody.module.scss'; -import ShareVerseActionsMenu, { VerseActionsOverflowMenu } from './ShareVerseActionsMenu'; +import ShareVerseActionsMenu from './ShareVerseActionsMenu'; +import VerseActionsMenuType from '@/components/QuranReader/ReadingView/WordActionsMenu/types'; import WordByWordVerseAction from '@/components/QuranReader/ReadingView/WordByWordVerseAction'; -import IconContainer, { IconSize } from '@/dls/IconContainer/IconContainer'; -import PopoverMenu from '@/dls/PopoverMenu/PopoverMenu'; -import ChevronRightIcon from '@/icons/chevron-right.svg'; -import ShareIcon from '@/icons/share.svg'; +import { WordVerse } from '@/types/Word'; import { isLoggedIn } from '@/utils/auth/login'; -import { logButtonClick } from '@/utils/eventLogger'; -import Verse from 'types/Verse'; interface Props { - verse: Verse; + verse: WordVerse; isTranslationView: boolean; onActionTriggered?: () => void; bookmarksRangeUrl: string; @@ -32,27 +25,10 @@ const OverflowVerseActionsMenuBody: React.FC = ({ onActionTriggered, bookmarksRangeUrl, }) => { - const { t } = useTranslation('common'); - const [selectedMenu, setSelectedMenu] = useState( - VerseActionsOverflowMenu.Main, - ); - const onShareItemClicked = () => { - logButtonClick(`share_verse_action`); - setSelectedMenu(VerseActionsOverflowMenu.Share); - }; + const [selectedMenu, setSelectedMenu] = useState(VerseActionsMenuType.Main); - return selectedMenu === VerseActionsOverflowMenu.Main ? ( + return selectedMenu === VerseActionsMenuType.Main ? (
- {!isTranslationView && } - {!isTranslationView && ( - - )} - {isLoggedIn() && ( = ({ isTranslationView={isTranslationView} /> )} - } onClick={onShareItemClicked}> -
- {t('share')} -
- } - shouldFlipOnRTL - size={IconSize.Small} - /> -
-
-
+ + +
) : ( = ({
= ({ reciterId: reciterQueryParamDifferent ? reciterId : undefined, }); - if (onActionTriggered) { - onActionTriggered(); - } + onActionTriggered?.(); // if the user clicks on the play button while the onboarding is active, we should automatically go to the next step if (isActive && activeStepGroup === OnboardingGroup.READING_EXPERIENCE && isVisible) { @@ -120,13 +119,16 @@ const PlayVerseAudioButton: React.FC = ({ shouldFlipOnRTL={false} shape={ButtonShape.Circle} id="play-verse-button" // this ID is for onboarding - className={classNames(styles.iconContainer, styles.verseAction, { - [styles.fadedVerseAction]: isTranslationView, - })} + className={classNames(styles.iconContainer, styles.verseAction)} ariaLabel={t('aria.play-surah', { surahName: chapterData.transliteratedName })} > - + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> ); diff --git a/src/components/Verse/SaveToCollectionAction.tsx b/src/components/Verse/SaveToCollectionAction.tsx index 7daa975dba..e9de201db2 100644 --- a/src/components/Verse/SaveToCollectionAction.tsx +++ b/src/components/Verse/SaveToCollectionAction.tsx @@ -1,213 +1,134 @@ -/* eslint-disable max-lines */ -/* eslint-disable react-func/max-lines-per-function */ -import { useState } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import useTranslation from 'next-translate/useTranslation'; import { shallowEqual, useSelector } from 'react-redux'; -import useSWR, { useSWRConfig } from 'swr'; -import useSWRImmutable from 'swr/immutable'; import SaveToCollectionModal, { - Collection, + CollectionOption, } from '../Collection/SaveToCollectionModal/SaveToCollectionModal'; -import PopoverMenu from '../dls/PopoverMenu/PopoverMenu'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import PopoverMenu from '@/dls/PopoverMenu/PopoverMenu'; +import useBookmarkCollections from '@/hooks/useBookmarkCollections'; +import useCollections from '@/hooks/useCollections'; import PlusIcon from '@/icons/plus.svg'; +import { WordVerse } from '@/types/Word'; import { ToastStatus, useToast } from 'src/components/dls/Toast/Toast'; import { selectQuranReaderStyles } from 'src/redux/slices/QuranReader/styles'; import { getMushafId } from 'src/utils/api'; -import { - addCollection, - addCollectionBookmark, - deleteCollectionBookmarkByKey, - getBookmarkCollections, - getCollectionsList, -} from 'src/utils/auth/api'; -import { - makeBookmarkCollectionsUrl, - makeBookmarksUrl, - makeCollectionsUrl, - makeBookmarkUrl, -} from 'src/utils/auth/apiPaths'; -import { isLoggedIn } from 'src/utils/auth/login'; import { logButtonClick } from 'src/utils/eventLogger'; import BookmarkType from 'types/BookmarkType'; -const SaveToCollectionAction = ({ verse, bookmarksRangeUrl, isTranslationView }) => { +interface Props { + verse: WordVerse; + isTranslationView: boolean; + bookmarksRangeUrl?: string; +} + +/** + * Action component for saving verses to collections + * Uses extracted hooks for cleaner separation of concerns + * + * @returns {JSX.Element} The save to collection action component + */ +const SaveToCollectionAction: React.FC = ({ + verse, + isTranslationView, + bookmarksRangeUrl, +}) => { const [isSaveCollectionModalOpen, setIsSaveCollectionModalOpen] = useState(false); const quranReaderStyles = useSelector(selectQuranReaderStyles, shallowEqual); const mushafId = getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf; const { t } = useTranslation(); - const { data: collectionListData, mutate: mutateCollectionListData } = useSWR( - isLoggedIn() ? makeCollectionsUrl({}) : null, - () => getCollectionsList({}), - ); - - const { mutate: globalSWRMutate } = useSWRConfig(); - - const { data: bookmarkCollectionIdsData, mutate: mutateBookmarkCollectionIdsData } = - useSWRImmutable( - isLoggedIn() - ? makeBookmarkCollectionsUrl( - mushafId, - Number(verse.chapterId), - BookmarkType.Ayah, - Number(verse.verseNumber), - ) - : null, - async () => { - const response = await getBookmarkCollections( - mushafId, - Number(verse.chapterId), - BookmarkType.Ayah, - Number(verse.verseNumber), - ); - return response; - }, - ); - const toast = useToast(); - const onMenuClicked = () => { + // Use extracted hooks for data fetching and mutations + const { collections, addCollection } = useCollections({ + type: BookmarkType.Ayah, + }); + + const { + collectionIds: bookmarkCollectionIds, + addToCollection, + removeFromCollection, + } = useBookmarkCollections({ + mushafId, + key: Number(verse.chapterId), + type: BookmarkType.Ayah, + verseNumber: verse.verseNumber, + bookmarksRangeUrl, + }); + + const onMenuClicked = useCallback(() => { setIsSaveCollectionModalOpen(true); if (isTranslationView) { logButtonClick('save_to_collection_menu_trans_view'); } else { logButtonClick('save_to_collection_menu_reading_view'); } - }; + }, [isTranslationView]); - const closeModal = () => { + const closeModal = useCallback(() => { setIsSaveCollectionModalOpen(false); - }; - - const mutateIsResourceBookmarked = () => { - if (!isLoggedIn()) { - return; - } - globalSWRMutate( - makeBookmarkUrl( - mushafId, - Number(verse.chapterId), - BookmarkType.Ayah, - Number(verse.verseNumber), - ), - ); - - if (bookmarksRangeUrl) { - globalSWRMutate(bookmarksRangeUrl); - } - }; - - const mutateBookmarksUrl = () => - globalSWRMutate( - makeBookmarksUrl( - getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines).mushaf, - ), - ); + }, []); - const onCollectionToggled = (changedCollection: Collection, newValue: boolean) => { - if (newValue === true) { - addCollectionBookmark({ - key: Number(verse.chapterId), - mushaf: mushafId, - type: BookmarkType.Ayah, - verseNumber: verse.verseNumber, - collectionId: changedCollection.id, - }) - .then(() => { + const onCollectionToggled = useCallback( + async (changedCollection: CollectionOption, newValue: boolean) => { + if (newValue) { + const success = await addToCollection(changedCollection.id); + if (success) { toast(t('quran-reader:saved-to', { collectionName: changedCollection.name }), { status: ToastStatus.Success, }); - mutateIsResourceBookmarked(); - mutateCollectionListData(); - mutateBookmarkCollectionIdsData(); - mutateBookmarksUrl(); - }) - .catch((err) => { - if (err.status === 400) { - toast(t('common:error.bookmark-sync'), { - status: ToastStatus.Error, - }); - return; - } - toast(t('common:error.general'), { - status: ToastStatus.Error, - }); - }); - } else { - deleteCollectionBookmarkByKey({ - key: Number(verse.chapterId), - mushaf: mushafId, - type: BookmarkType.Ayah, - verseNumber: verse.verseNumber, - collectionId: changedCollection.id, - }) - .then(() => { + } + } else { + const success = await removeFromCollection(changedCollection.id); + if (success) { toast(t('quran-reader:removed-from', { collectionName: changedCollection.name }), { status: ToastStatus.Success, }); - mutateIsResourceBookmarked(); - mutateCollectionListData(); - mutateBookmarkCollectionIdsData(); - mutateBookmarksUrl(); - }) - .catch((err) => { - if (err.status === 400) { - toast(t('common:error.bookmark-sync'), { - status: ToastStatus.Error, - }); - return; - } - toast(t('common:error.general'), { - status: ToastStatus.Error, - }); - }); - } - }; + } + } + }, + [addToCollection, removeFromCollection, toast, t], + ); - const onNewCollectionCreated = (newCollectionName: string) => { - return addCollection(newCollectionName).then((newCollection: any) => { - addCollectionBookmark({ - collectionId: newCollection.id, - key: Number(verse.chapterId), - mushaf: mushafId, - type: BookmarkType.Ayah, - verseNumber: verse.verseNumber, - }) - .then(() => { - mutateIsResourceBookmarked(); - mutateCollectionListData(); - mutateBookmarkCollectionIdsData([...bookmarkCollectionIdsData, newCollection.id]); - mutateBookmarksUrl(); - }) - .catch((err) => { - if (err.status === 400) { - toast(t('common:error.bookmark-sync'), { - status: ToastStatus.Error, - }); - return; - } - toast(t('common:error.general'), { - status: ToastStatus.Error, - }); - }); - }); - }; + const onNewCollectionCreated = useCallback( + async (newCollectionName: string) => { + const newCollection = await addCollection(newCollectionName); + if (newCollection) { + // addToCollection handles both the API call and cache mutation internally + await addToCollection(newCollection.id); + } + }, + [addCollection, addToCollection], + ); - const isDataReady = bookmarkCollectionIdsData && collectionListData; + const isDataReady = bookmarkCollectionIds !== undefined; - const collections = !isDataReady - ? [] - : (collectionListData.data.map((collection) => ({ + const modalCollections: CollectionOption[] = useMemo( + () => + collections.map((collection) => ({ id: collection.id, name: collection.name, - checked: bookmarkCollectionIdsData?.includes(collection.id), - })) as Collection[]); + checked: bookmarkCollectionIds?.includes(collection.id) ?? false, + })), + [collections, bookmarkCollectionIds], + ); return ( <> - }> + } + color={IconColor.tertiary} + size={IconSize.Custom} + shouldFlipOnRTL={false} + /> + } + > {t('common:save-to-collection')} {isDataReady && ( @@ -216,7 +137,7 @@ const SaveToCollectionAction = ({ verse, bookmarksRangeUrl, isTranslationView }) onCollectionToggled={onCollectionToggled} onNewCollectionCreated={onNewCollectionCreated} onClose={closeModal} - collections={collections} + collections={modalCollections} verseKey={`${verse.chapterId}:${verse.verseNumber}`} /> )} diff --git a/src/components/Verse/ShareButton.tsx b/src/components/Verse/ShareButton.tsx new file mode 100644 index 0000000000..c2e07a2468 --- /dev/null +++ b/src/components/Verse/ShareButton.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; + +import styles from '../QuranReader/TranslationView/TranslationViewCell.module.scss'; + +import ShareVerseActionsMenu from './OverflowVerseActionsMenuBody/ShareVerseActionsMenu'; + +import PopoverMenu from '@/components/dls/PopoverMenu/PopoverMenu'; +import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import ShareIcon from '@/icons/share.svg'; +import { WordVerse } from '@/types/Word'; +import { logButtonClick } from '@/utils/eventLogger'; + +type ShareButtonProps = { + verse: WordVerse; + isTranslationView?: boolean; + isMenu?: boolean; + onClick?: () => void; +}; + +/** + * ShareButton component that displays a share button for a verse + * Opens a ShareVerseActionsMenu when clicked + * @returns {JSX.Element} JSX element containing the share button + */ +const ShareButton: React.FC = ({ + verse, + isTranslationView = false, + isMenu, + onClick, +}) => { + const { t } = useTranslation('common'); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const onActionTriggered = () => { + setIsMenuOpen(false); + }; + + const handleClick = () => { + logButtonClick('verse_share_button'); + onClick?.(); + setIsMenuOpen(true); + }; + + const trigger = ( + + ); + + if (!isMenu) { + return trigger; + } + + return ( + + {}} + hasBackButton={false} + /> + + ); +}; + +export default ShareButton; diff --git a/src/components/Verse/TranslationFeedback/TranslationFeedbackAction.module.scss b/src/components/Verse/TranslationFeedback/TranslationFeedbackAction.module.scss new file mode 100644 index 0000000000..5f9eb6bbce --- /dev/null +++ b/src/components/Verse/TranslationFeedback/TranslationFeedbackAction.module.scss @@ -0,0 +1,28 @@ +@use 'src/styles/breakpoints'; +@use 'src/styles/constants'; + +.title { + font-size: var(--font-size-xlarge); + font-weight: var(--font-weight-bold); + color: var(--color-text-faded); + + @include breakpoints.smallerThanTablet { + font-size: var(--font-size-large2); + } +} + +.overlay { + @include breakpoints.smallerThanTablet { + place-items: end; + } +} + +.content { + max-width: constants.$translation-feedback-modal-max-width; + + @include breakpoints.smallerThanTablet { + height: auto; + max-height: 100%; + padding-bottom: var(--spacing-large); + } +} diff --git a/src/components/Verse/TranslationFeedback/TranslationFeedbackAction.tsx b/src/components/Verse/TranslationFeedback/TranslationFeedbackAction.tsx new file mode 100644 index 0000000000..8194dbdb42 --- /dev/null +++ b/src/components/Verse/TranslationFeedback/TranslationFeedbackAction.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { useRouter } from 'next/router'; +import useTranslation from 'next-translate/useTranslation'; + +import styles from './TranslationFeedbackAction.module.scss'; +import TranslationFeedbackModal from './TranslationFeedbackModal'; + +import ContentModal from '@/dls/ContentModal/ContentModal'; +import IconContainer, { IconColor, IconSize } from '@/dls/IconContainer/IconContainer'; +import PopoverMenu from '@/dls/PopoverMenu/PopoverMenu'; +import FeedbackIcon from '@/icons/translation-feedback.svg'; +import { WordVerse } from '@/types/Word'; +import { isLoggedIn } from '@/utils/auth/login'; +import { logEvent } from '@/utils/eventLogger'; +import { getChapterWithStartingVerseUrl, getLoginNavigationUrl } from '@/utils/navigation'; + +interface TranslationFeedbackActionProps { + verse: WordVerse; + isTranslationView: boolean; + onActionTriggered?: () => void; +} + +const CLOSE_POPOVER_AFTER_MS = 150; + +const TranslationFeedbackAction: React.FC = ({ + verse, + isTranslationView, + onActionTriggered, +}) => { + const router = useRouter(); + const { t } = useTranslation('common'); + const [isModalOpen, setIsModalOpen] = useState(false); + const closeTimeoutRef = useRef>(); + + const getEventName = useCallback( + (action: string) => + `${ + isTranslationView ? 'translation_view' : 'reading_view' + }_translation_feedback_modal_${action}`, + [isTranslationView], + ); + + const onModalClose = useCallback(() => { + logEvent(getEventName('close')); + + setIsModalOpen(false); + + if (onActionTriggered) { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + + closeTimeoutRef.current = setTimeout(() => { + onActionTriggered(); + }, CLOSE_POPOVER_AFTER_MS); + } + }, [getEventName, onActionTriggered]); + + const onModalOpen = useCallback(() => { + logEvent(getEventName('open')); + setIsModalOpen(true); + }, [getEventName]); + + /** + * Handles click events for guest users, redirecting to login if not authenticated, + * otherwise opens the translation feedback modal. + */ + const handleGuestUserClick = useCallback(() => { + if (!isLoggedIn()) { + router.push(getLoginNavigationUrl(getChapterWithStartingVerseUrl(verse.verseKey))); + return; + } + + onModalOpen(); + }, [router, verse.verseKey, onModalOpen]); + + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + + return ( + <> + } + color={IconColor.tertiary} + size={IconSize.Custom} + /> + } + onClick={handleGuestUserClick} + > + {t('translation-feedback.title')} + + + {t('translation-feedback.title')}

} + hasCloseButton + onClose={onModalClose} + contentClassName={styles.content} + overlayClassName={styles.overlay} + onEscapeKeyDown={onModalClose} + > + +
+ + ); +}; + +export default TranslationFeedbackAction; diff --git a/src/components/Verse/TranslationFeedback/TranslationFeedbackModal.module.scss b/src/components/Verse/TranslationFeedback/TranslationFeedbackModal.module.scss new file mode 100644 index 0000000000..8853c74338 --- /dev/null +++ b/src/components/Verse/TranslationFeedback/TranslationFeedbackModal.module.scss @@ -0,0 +1,41 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--spacing-medium); + + margin-block-start: var(--spacing-medium2); +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: var(--spacing-xxsmall); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-xsmall); +} + +.selectContainer { + select { + inline-size: 100%; + } +} + +.textArea { + padding: 0; + + textarea { + padding: var(--spacing-xsmall); + + &:focus::placeholder { + color: transparent; + } + } +} + +.error { + color: var(--color-text-error); +} diff --git a/src/components/Verse/TranslationFeedback/TranslationFeedbackModal.tsx b/src/components/Verse/TranslationFeedback/TranslationFeedbackModal.tsx new file mode 100644 index 0000000000..08f3672e14 --- /dev/null +++ b/src/components/Verse/TranslationFeedback/TranslationFeedbackModal.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import styles from './TranslationFeedbackModal.module.scss'; +import TranslationPreview from './TranslationPreview'; +import useTranslationFeedbackForm from './useTranslationFeedbackForm'; + +import Button, { ButtonSize } from '@/dls/Button/Button'; +import Select from '@/dls/Forms/Select'; +import TextArea from '@/dls/Forms/TextArea'; +import { WordVerse } from '@/types/Word'; + +interface TranslationFeedbackModalProps { + verse: WordVerse; + onClose: () => void; +} + +const TranslationFeedbackModal: React.FC = ({ verse, onClose }) => { + const { t } = useTranslation('common'); + + const { + selectedTranslationId, + feedback, + errors, + isSubmitting, + selectOptions, + onSubmit, + handleTranslationChange, + handleFeedbackChange, + } = useTranslationFeedbackForm({ verse, onClose }); + + return ( +
+
+ + +