diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..b0c6455 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,70 @@ +# Architecture +Web2, java spring backend - JDK 17, expo,react native frontend,r2 storage,supabase +# Conventions + +# Commands + +# prohibited pattern + +## 금지 - basic +- critical한 파일 임의 수정 금지 + +## 금지 — Backend +- req.body 직접 DB, userId from body +- any 타입, 에러 raw throw + +## 금지 — Frontend +- useEffect 안 fetch, 인라인 스타일 + +# Testing & Verify + +# Past Failures -> Rules +- 실패/의도와 다른 에러 발생 시 여기에 한줄씩 추가 + +# Workflow Rules + +- 새 모듈/기능 작업 시작 전, 첫 코드 작성 직전에 advisor 호출. + 형식: "Approach check: [모듈명], [핵심 판단 지점 2-3개]" + → Skill("advisor") 로드 후 Agent(model: "opus")로 위임. 응답은 100단어 이내, 단계 나열로만. + +- "작업 완료" 선언 직전 advisor 재호출. + 형식: "Completion check: [변경된 파일], [회귀 우려 지점]" + → Skill("advisor") 로드 후 Agent(model: "opus")로 위임. 응답은 100단어 이내. + +- Plan 문서(plan.md) 없이 Generate 단계 진입 금지. + plan.md는 Codex가 작성, Claude Code가 비판적 리뷰 후 v2 확정한 것만 유효. + +# Cross-Review Loop +- 작업 완료 advisor 호출 후, Codex 세션에 review.md 작성 요청. +- review.md에 issue 1건 이상이면 Claude Code 세션 재개하여 수정. +- 수정 완료 후 같은 review.md에 "RESOLVED: [항목]" 추가하고 Codex 재검증 요청. +- review.md v2도 issue 0건이어야 PR 가능. +- 3회 사이클 후에도 미해결 시 사용자 에스컬레이션. 자동 진행 금지. + +# Cross-Review Loop (필수) + +작업 완료 선언 직전 advisor 호출 후, 다음 2-pass 교차 검증을 거쳐야 PR 가능: + +## Pass 1 — Codex 검증 +- Claude Code 세션 종료 +- 별도 Codex 세션에서 review.md 작성 +- review.md는 다음 3가지를 명시: + 1. plan.md Sprint Contract 미충족 항목 (있으면 모두 나열) + 2. 자동 체크(test/lint/slither/build) 실패 항목 + 3. plan.md에 없던 변경사항 (scope creep 식별) +- review.md에 issue 0건이면 Pass 1 완료, 1건 이상이면 Claude Code 세션 재개 + +## Pass 2 — Claude Code 수정 + 자체 재검증 +- Codex review.md 읽고 수정 작업 수행 +- 수정 후 자체 테스트/lint 재실행 +- 수정 완료 시 review.md에 "RESOLVED: [항목]" 한 줄씩 추가 +- 모든 issue가 RESOLVED 되면 작업 완료 advisor 재호출 + +## Pass 3 — Codex 재검증 +- 별도 Codex 세션에서 review.md v2 작성 +- v2에서도 issue 발견되면 Pass 2로 복귀 +- v2 issue 0건이면 PR 생성 가능 + +## 최대 반복 횟수 +- Pass 1 → Pass 2 → Pass 3 사이클은 최대 3회. +- 3회 후에도 issue 잔존 시 사용자에게 에스컬레이션. 자동 머지 절대 금지. \ No newline at end of file diff --git a/.claude/hooks/force-advisor-check.sh b/.claude/hooks/force-advisor-check.sh new file mode 100644 index 0000000..1f2aaa6 --- /dev/null +++ b/.claude/hooks/force-advisor-check.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Stop hook — fail-open 원칙: 훅 자체가 실패하면 차단하지 않고 통과 + +INPUT=$(cat) + +# jq 없으면 즉시 통과 (block 안 함) +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +# JSON 파싱 실패 시 통과 +TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null) + +# 무한루프 방지 — 한 번 block 한 적 있으면 무조건 통과 +[ "$STOP_HOOK_ACTIVE" = "true" ] && exit 0 + +# transcript 경로가 비었거나 파일이 없으면 통과 +[ -z "$TRANSCRIPT" ] && exit 0 +[ ! -f "$TRANSCRIPT" ] && exit 0 + +# transcript 끝부분 읽기 실패 시 통과 +RECENT=$(tail -c 20000 "$TRANSCRIPT" 2>/dev/null) || exit 0 + +# Completion check 이미 했으면 통과 +echo "$RECENT" | grep -q "Completion check" && exit 0 + +# 여기까지 와야 block 발동 +cat <<'EOF' +{ + "decision": "block", + "reason": "종료 전 강제 advisor 호출 누락. 형식: 'Completion check: [변경 파일 리스트], [회귀 우려 지점 2-3개]'. 100단어 이내 응답을 받은 후 종료할 것." +} +EOF \ No newline at end of file diff --git a/.claude/hooks/track-failures.sh b/.claude/hooks/track-failures.sh new file mode 100644 index 0000000..3772e87 --- /dev/null +++ b/.claude/hooks/track-failures.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# .claude/hooks/track-failures.sh +# PostToolUse(Bash) — Bash 출력에서 실패 패턴 추적 + +INPUT=$(cat) +TOOL_OUTPUT=$(echo "$INPUT" | jq -r '.tool_response.stdout // ""') +TOOL_STDERR=$(echo "$INPUT" | jq -r '.tool_response.stderr // ""') + +# 실패 시그니처 추출 (테스트명/에러 첫 줄) +SIGNATURE=$(echo "$TOOL_STDERR$TOOL_OUTPUT" \ + | grep -E "FAIL|Error:|revert|✗" \ + | head -1 \ + | sha256sum | cut -c1-16) + +[ -z "$SIGNATURE" ] && exit 0 # 실패 없으면 종료 + +# 카운터 파일에 누적 +COUNTER_FILE="/tmp/claude-failures-$$.log" +COUNT=$(grep -c "^$SIGNATURE$" "$COUNTER_FILE" 2>/dev/null || echo 0) +echo "$SIGNATURE" >> "$COUNTER_FILE" + +if [ "$COUNT" -ge 1 ]; then + # 2회째 — advisor 호출 강제 + cat <" +}) +``` + +## 응답 형식 (Opus에게 전달할 프롬프트에 포함) + +- 100단어 이내 +- 단계 나열로만 (산문 금지) +- Approach check: 접근 순서 + 핵심 판단 지점만 +- Completion check: 회귀 위험 항목 + 검증 필요 포인트만 diff --git a/.claude/skills/backend-spring/SKILL.md b/.claude/skills/backend-spring/SKILL.md new file mode 100644 index 0000000..3ea4309 --- /dev/null +++ b/.claude/skills/backend-spring/SKILL.md @@ -0,0 +1,507 @@ +--- +name: backend-spring +description: "Use this skill when writing or reviewing Spring Boot REST controllers, services, JPA repositories, or Java backend code. Covers DTO separation, Bean Validation, security context auth, standard response formats with @RestControllerAdvice, @Transactional placement, N+1 query prevention, and constructor injection. Triggers on Spring, Spring Boot, Java backend, JPA, Hibernate, REST controller keywords." +--- + +# Spring Boot Backend 규칙 + +## 절대 쓰지 말 것 + +``` +- Entity를 Controller에서 직접 받거나 반환 (반드시 DTO 분리) +- @Autowired 필드 주입 (생성자 주입만, Lombok @RequiredArgsConstructor 권장) +- @Transactional을 Controller에 붙이기 (Service 계층에만) +- @Data on JPA Entity (equals/hashCode/toString 무한루프 + 영속성 깨짐) +- Optional를 필드 / 파라미터 / Entity 필드에 사용 (반환 타입에만) +- userId / tenantId를 RequestBody에서 받기 (반드시 SecurityContext에서 추출) +- findAll() 후 N+1 쿼리 발생 (fetch join 또는 EntityGraph 명시) +- 트랜잭션 내에서 외부 API 호출 (커넥션 점유 + 롤백 일관성 깨짐) +- Native query에서 SELECT * (컬럼 명시) +- Setter 남발 (Builder, record, with-style 메서드 사용) +- Open Session In View true (application.yml에 false 명시) +- 예외를 그냥 throw new RuntimeException (도메인 예외 클래스 사용) +- Lombok @Value 또는 @Getter on Entity (record / explicit accessor 사용) +``` + +--- + +## DTO + Bean Validation + +모든 RequestBody는 DTO로 받고 `@Valid` 적용: + +```java +// dto//CreateRequest.java +package .dto.; + +import jakarta.validation.constraints.*; +import java.util.UUID; + +public record CreateRequest( + @NotNull + UUID , + + @NotBlank + @Size(min = 1, max = 100) + String title +) {} + +public record CreateRequest( + @NotBlank + String content, + + String context, // optional은 그대로 둠 + + UUID linkedId // optional은 그대로 둠 +) {} + +public record Request( + @NotNull + Method method, // enum + + UUID presetId, + + @Pattern(regexp = "^https?://.*") + String destination +) {} +``` + +UUID 검증은 타입 자체로 처리됨(파싱 실패 시 400). 추가 검증이 필요하면 커스텀 validator. + +사용법: + +```java +@PostMapping("/api/") +public ResponseEntityResponse>> create( + @Valid @RequestBody CreateRequest request +) { + // 파싱/검증 실패 시 자동 400 + ... +} +``` + +--- + +## userId는 SecurityContext에서만 + +```java +// 잘못된 것 +@PostMapping("/api/") +public ResponseEntity create(@RequestBody CreateRequest req) { + UUID userId = req.userId(); // ← 클라이언트 조작 가능 + ... +} + +// 올바른 것 — @AuthenticationPrincipal 사용 +@PostMapping("/api/") +public ResponseEntityResponse>> create( + @AuthenticationPrincipal CustomUserDetails principal, + @Valid @RequestBody CreateRequest request +) { + if (principal == null) throw new ForbiddenException(); + UUID userId = principal.getUserId(); // ← 서버가 검증한 값 + ... +} +``` + +`SecurityContextHolder.getContext().getAuthentication()` 직접 호출은 테스트성·결합도 때문에 비권장. 컨트롤러 시그니처에 `@AuthenticationPrincipal`로 박는 게 표준. + +--- + +## 표준 응답 포맷 — `ApiResponse` + `@RestControllerAdvice` + +공통 응답 클래스: + +```java +// common/ApiResponse.java +public record ApiResponse( + boolean ok, + T data, + String error +) { + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null); + } + public static ApiResponse error(String message) { + return new ApiResponse<>(false, null, message); + } +} +``` + +도메인 예외: + +```java +// common/exception/DomainException.java +public abstract class DomainException extends RuntimeException { + public abstract HttpStatus status(); + protected DomainException(String message) { super(message); } +} + +public class BadRequestException extends DomainException { + public BadRequestException(String message) { super(message); } + public HttpStatus status() { return HttpStatus.BAD_REQUEST; } +} + +public class ForbiddenException extends DomainException { + public ForbiddenException() { super("forbidden"); } + public HttpStatus status() { return HttpStatus.FORBIDDEN; } +} + +public class NotFoundException extends DomainException { + public NotFoundException(String resource) { super("not_found: " + resource); } + public HttpStatus status() { return HttpStatus.NOT_FOUND; } +} +``` + +전역 핸들러: + +```java +// common/GlobalExceptionHandler.java +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(DomainException.class) + public ResponseEntity> handleDomain(DomainException e) { + return ResponseEntity.status(e.status()) + .body(ApiResponse.error(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation( + MethodArgumentNotValidException e + ) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(err -> err.getField() + ": " + err.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity.badRequest().body(ApiResponse.error(message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnknown(Exception e) { + log.error("Unhandled", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("server_error")); + } +} +``` + +Controller에서는 정상 응답만 신경 쓰면 됨. `try/catch` 금지 — 전역 핸들러가 잡음. + +--- + +## 상태 전환 — 중앙 함수 통과 필수 + +Entity 안에 도메인 로직으로 박는 게 정석: + +```java +// domain/.java +@Entity +@Table(name = "") +public class { + + @Id + private UUID id; + + @Enumerated(EnumType.STRING) + private Status status; + + private static final Map<Status, Set<Status>> VALID_TRANSITIONS = Map.of( + Status.DRAFT, Set.of(Status.IN_REVIEW), + Status.IN_REVIEW, Set.of(Status.APPROVED, Status.REJECTED, Status.DRAFT), + Status.APPROVED, Set.of(), + Status.REJECTED, Set.of(Status.DRAFT) + ); + + public void changeStatus(Status to) { + Set<Status> allowed = VALID_TRANSITIONS.get(this.status); + if (allowed == null || !allowed.contains(to)) { + throw new BadRequestException( + "invalid transition: " + this.status + " → " + to + ); + } + this.status = to; + } +} +``` + +Service에서는 `entity.changeStatus(...)`만 호출. Setter 직접 호출 금지. + +--- + +## Controller 기본 구조 + +```java +// controller/Controller.java +@RestController +@RequestMapping("/api/") +@RequiredArgsConstructor // 생성자 주입 (Lombok) +public class Controller { + + private final Service Service; + + @PostMapping + public ResponseEntityResponse>> create( + @AuthenticationPrincipal CustomUserDetails principal, + @Valid @RequestBody CreateRequest request + ) { + if (principal == null) throw new ForbiddenException(); + + Response response = Service.create( + request, + principal.getUserId() + ); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ApiResponse.ok(response)); + } + + @GetMapping("/{}") + public ResponseEntityResponse>> get( + @AuthenticationPrincipal CustomUserDetails principal, + @PathVariable UUID + ) { + if (principal == null) throw new ForbiddenException(); + + Response response = Service.findById( + , + principal.getUserId() + ); + + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} +``` + +Controller는 **얇게**. 검증·인증·라우팅만. 비즈니스 로직 전부 Service. + +--- + +## Service 계층 규칙 + +```java +// service/Service.java +@Service +@RequiredArgsConstructor +public class Service { + + private final Repository Repository; + private final Repository Repository; + + @Transactional // ← 쓰기는 트랜잭션 명시 + public Response create(CreateRequest request, UUID userId) { + // 1. 사전 조건 확인 + parent = Repository + .findById(request.()) + .orElseThrow(() -> new NotFoundException("")); + + if (!parent.isAccessibleBy(userId)) { + throw new ForbiddenException(); + } + + // 2. 도메인 객체 생성 + entity = .create(parent, request.title(), userId); + + // 3. 저장 + Repository.save(entity); + + // 4. 응답 변환 + return Response.from(entity); + } + + @Transactional(readOnly = true) // ← 읽기는 readOnly 명시 + public Response findById(UUID , UUID userId) { + entity = Repository + .findByIdWithParent() // fetch join 메서드 + .orElseThrow(() -> new NotFoundException("")); + + if (!entity.isAccessibleBy(userId)) { + throw new ForbiddenException(); + } + + return Response.from(entity); + } +} +``` + +규칙: +- 쓰기 메서드: `@Transactional` +- 읽기 메서드: `@Transactional(readOnly = true)` — 1차 캐시 정리 + 성능 +- 트랜잭션 안에서 외부 HTTP 호출 / 메시지 발행 금지 — 트랜잭션 커밋 후 발행하려면 `ApplicationEventPublisher` + `@TransactionalEventListener(phase = AFTER_COMMIT)` + +--- + +## Repository — N+1 명시적 회피 + +```java +// repository/Repository.java +public interface Repository extends JpaRepository<, UUID> { + + // 잘못된 것 — findAll() 호출 후 entity.getParent() 접근하면 N+1 + // 올바른 것 — fetch join 명시 + @Query(""" + SELECT e FROM e + JOIN FETCH e. + WHERE e.id = :id + """) + Optional<> findByIdWithParent(@Param("id") UUID id); + + // 또는 @EntityGraph + @EntityGraph(attributePaths = {"", ""}) + Optional<> findById(UUID id); + + // 페이징 + fetch join은 주의 — Hibernate가 in-memory paging으로 fallback + // 페이징이 필요하면 ID만 먼저 가져온 뒤 별도 쿼리로 fetch +} +``` + +Native query 사용 시: + +```java +@Query(value = """ + SELECT id, title, status, created_at + FROM + WHERE _id = : +""", nativeQuery = true) +List<Projection> findProjectionsByParent( + @Param("") UUID +); +``` + +`SELECT *` 금지. 필요한 컬럼만. + +--- + +## Entity vs DTO 분리 + +```java +// domain/.java — 영속 객체 +@Entity +@Table(name = "") +@Access(AccessType.FIELD) +public class { + + @Id + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) // ← LAZY가 기본 + @JoinColumn(name = "_id") + private ; + + private String title; + + @Enumerated(EnumType.STRING) + private Status status; + + private UUID createdBy; + + private Instant createdAt; + + protected () {} // JPA 요구사항, protected로 외부 접근 차단 + + public static create( parent, String title, UUID userId) { + e = new (); + e.id = UUID.randomUUID(); + e. = parent; + e.title = title; + e.status = Status.DRAFT; + e.createdBy = userId; + e.createdAt = Instant.now(); + return e; + } + + public boolean isAccessibleBy(UUID userId) { + return this..isMember(userId); + } + + // Setter 없음. 상태 변경은 도메인 메서드(changeStatus 등)로만 +} +``` + +```java +// dto//Response.java — 외부 응답 +public record Response( + UUID id, + UUID , + String title, + String status, + UUID createdBy, + Instant createdAt +) { + public static Response from( entity) { + return new Response( + entity.getId(), + entity.get().getId(), + entity.getTitle(), + entity.getStatus().name(), + entity.getCreatedBy(), + entity.getCreatedAt() + ); + } +} +``` + +규칙: **Entity는 절대로 Controller 시그니처에 등장하지 않음**. + +--- + +## application.yml 기본 설정 + +```yaml +spring: + jpa: + open-in-view: false # ← 반드시 false + hibernate: + ddl-auto: validate # 운영은 validate, 개발도 가급적 update만 + properties: + hibernate: + jdbc.batch_size: 50 + order_inserts: true + order_updates: true + default_batch_fetch_size: 100 # N+1 완화 안전망 + +logging: + level: + org.hibernate.SQL: DEBUG # 개발 환경에서만 + org.hibernate.orm.jdbc.bind: TRACE # 바인드 파라미터 확인 +``` + +`open-in-view: true`(Spring 기본값)는 컨트롤러까지 영속성 컨텍스트가 유지돼서 **숨겨진 N+1 쿼리의 주범**. 무조건 `false`. + +--- + +## API 완성 기준 + +``` +□ 모든 @RequestBody에 DTO + @Valid 적용됨 +□ Entity가 Controller 시그니처에 노출되지 않음 +□ userId는 @AuthenticationPrincipal에서만 추출 +□ Controller에 try/catch 없음 (GlobalExceptionHandler가 처리) +□ Service 메서드에 @Transactional 또는 @Transactional(readOnly=true) 명시 +□ 트랜잭션 내부에 외부 HTTP 호출 / 메시지 발행 없음 +□ 상태 전환은 Entity의 도메인 메서드 통과 (Setter 직접 호출 없음) +□ findById가 fetch join 또는 EntityGraph 명시 (관련 객체 접근 시) +□ Native query에 SELECT * 없음 +□ Optional가 필드 / 파라미터에 사용되지 않음 +□ open-in-view: false 적용됨 +□ 생성자 주입 (필드 주입 @Autowired 없음) +□ Entity에 @Data, @Setter 없음 +``` + +--- + +## Placeholder 치환 가이드 + +| 플레이스홀더 | 의미 | 치환 예시 | +|---|---|---| +| `` | 베이스 패키지 | `com.example.app` | +| `` | 핵심 도메인 객체 (PascalCase) | `Order`, `Asset`, `Bundle` | +| `` | 변수/파일명 (camelCase) | `order`, `asset`, `bundle` | +| `` | URL 경로 / 테이블명 | `orders`, `assets`, `bundles` | +| `` | ID 변수명 | `orderId`, `assetId` | +| `` | 상위 엔티티 | `Project`, `Workspace`, `Task` | +| `` | 상위 엔티티 변수명 | `project`, `workspace` | +| `` | 상위 엔티티 ID | `projectId`, `taskId` | +| `` | 하위 엔티티 | `Revision`, `Comment`, `Item` | +| `` | 하위 컬렉션 필드명 | `revisions`, `comments` | +| `` | 연결 엔티티 | `Intent`, `Source`, `Tag` | +| `` | 액션 이름 | `Dispatch`, `Approve`, `Submit` | \ No newline at end of file diff --git a/.claude/skills/design-principles/SKILL.md b/.claude/skills/design-principles/SKILL.md new file mode 100644 index 0000000..963e6cf --- /dev/null +++ b/.claude/skills/design-principles/SKILL.md @@ -0,0 +1,244 @@ +--- +name: design-principles +description: "Use this skill when designing class structures, refactoring code for maintainability, evaluating coupling and cohesion, or applying SOLID principles. Triggers on SOLID, refactoring, design pattern, coupling, cohesion, single responsibility, dependency injection, interface segregation keywords." +--- + +# 설계 원칙 — SOLID & 결합도/응집도 + +## 핵심 방향 + +``` +결합도는 낮게 (모듈 간 의존 최소화) +응집도는 높게 (하나의 모듈 = 하나의 책임) +``` + +--- + +## 절대 쓰지 말 것 + +``` +- 전역 변수로 모듈 간 상태 공유 (공통 결합) +- public 필드 직접 접근 (내용 결합) +- boolean 플래그를 함수 인자로 넘겨 내부 분기 제어 (제어 결합) +- 하나의 클래스에서 저장 + 출력 + 계산 동시 처리 (단일책임 위반) +- 부모 클래스 메서드를 자식이 throw로 막기 (리스코프 위반) +- 쓰지 않는 인터페이스 메서드를 구현 강제 (인터페이스 분리 위반) +- 고수준 모듈 안에서 저수준 구현체 직접 new (의존역전 위반) +- 새 기능 추가 시 기존 함수 내부 수정 (개방폐쇄 위반) +``` + +--- + +## 결합도 — 나쁜 것부터 좋은 것 순서 + +| 등급 | 이름 | 특징 | 대처 | +|---|---|---|---| +| 최악 | 내용 결합 | 다른 모듈 내부 필드 직접 수정 | private + getter/setter | +| 나쁨 | 공통 결합 | 전역 변수 공유 | 생성자 주입으로 교체 | +| 나쁨 | 제어 결합 | boolean 플래그로 흐름 제어 | 함수 분리 또는 전략 패턴 | +| 보통 | 외부 결합 | 외부 자료구조 직접 참조 | 자료 결합으로 낮추기 | +| 보통 | 스탬프 결합 | 객체 전체를 넘김 | 필요한 필드만 추출해서 넘기기 | +| 좋음 | 자료 결합 | 기본 자료형만 주고받기 | 목표 상태 | + +--- + +## 제어 결합 — 가장 자주 실수하는 패턴 + +**잘못된 것:** + +```java +// boolean 플래그가 함수 내부 흐름을 결정 +.(, true); // true가 뭔지 호출부에서 알 수 없음 + +public void ([] , boolean ) { + if () { System.out.println("started"); } + // ... + if () { System.out.println("done"); } +} +``` + +**올바른 것:** + +```java +// 부가 책임은 별도 클래스가 담당 +public class { + public void ([] ) { /* 순수 처리만 */ } +} + +public class WithLogging { + private ; + public void ([] ) { + System.out.println("started"); + .(); + System.out.println("done"); + } +} +``` + +→ **함수에 boolean 인자가 있으면 제어 결합 의심**. 함수를 두 개로 나누거나 래퍼 클래스를 만들 것. + +--- + +## 응집도 — 낮은 것부터 높은 것 순서 + +| 등급 | 이름 | 특징 | +|---|---|---| +| 최악 | 우연적 | 연관 없는 기능들이 한 클래스에 | +| 나쁨 | 논리적 | switch/if로 유사한 기능 묶음 | +| 보통 | 시간적 | 초기화처럼 "같은 시점"에 실행 | +| 보통 | 절차적 | 순서대로 여러 기능 호출 | +| 좋음 | 교환적 | 같은 입력을 다른 방식으로 처리 | +| 좋음 | 순차적 | 이전 출력이 다음 입력으로 연결 | +| 최고 | 기능적 | 단 하나의 목적만 | + +--- + +## 논리적 응집도 — switch/if가 신호 + +**잘못된 것:** + +```java +// 새 타입 추가마다 이 함수를 수정해야 함 → 개방폐쇄 원칙 위반 +public void ( type, payload) { + switch (type) { + case : (payload); break; + case : (payload); break; + case : (payload); break; + } +} +``` + +**올바른 것:** + +```java +// 타입 추가 시 새 클래스만 추가, 기존 코드 수정 없음 +interface { void handle( payload); } + +class implements { ... } +class implements { ... } +class implements { ... } + +// 호출부 + handler = registry.resolve(type); +handler.handle(payload); +``` + +→ **switch/if로 타입 분기 중이면 인터페이스 분리 + 전략 패턴 적용** 검토. + +--- + +## SOLID — 위반 신호와 대처 + +### S — 단일 책임 + +``` +위반 신호: 하나의 클래스가 계산도 하고, 출력도 하고, DB 저장도 함 +대처: 역할별로 클래스 분리 + ( / / ) +``` + +### O — 개방 폐쇄 + +``` +위반 신호: 새 기능 추가할 때 기존 함수 내부를 열어서 else if 추가 +대처: 인터페이스 하나 만들고 새 클래스로 확장 (기존 코드 수정 없이) +``` + +### L — 리스코프 대체 + +``` +위반 신호: 자식 클래스의 오버라이드 메서드가 throw 던짐 +대처: 인터페이스를 분리, 불가능한 동작은 처음부터 계약에 넣지 않기 +``` + +```java +// 잘못된 것 +class extends { + @Override + public void () { throw new UnsupportedOperationException(); } +} + +// 올바른 것 +interface { void (); } +interface extends { void (); } +class implements { ... } +class implements { ... } +``` + +### I — 인터페이스 분리 + +``` +위반 신호: implements 후 일부 메서드를 빈 구현이나 throw로 채움 +대처: 인터페이스를 쪼갬. 클라이언트가 쓰는 메서드만 계약에 포함 +``` + +```java +// 잘못된 것 +class implements { + public void () { + throw new UnsupportedOperationException(); + } +} + +// 올바른 것 +interface { void (); } +interface { void (); } +class implements { ... } // 강제 없음 +class implements , { ... } +``` + +### D — 의존 역전 + +``` +위반 신호: 고수준 클래스 생성자 안에서 new <저수준구현체>() 직접 생성 +대처: 인터페이스에 의존, 구현체는 생성자 주입으로 외부에서 받기 +``` + +```java +// 잘못된 것 +class { + private = new (); +} + +// 올바른 것 +class { + private ; + public ( ) { + this. = ; + } +} +``` + +--- + +## 설계 완성 기준 + +``` +□ 함수 인자에 boolean 플래그가 없음 (제어 결합 없음) +□ public 필드가 없음, 모든 접근은 메서드 경유 (내용 결합 없음) +□ 전역 변수 없음, 상태는 생성자 주입으로 전달 (공통 결합 없음) +□ 하나의 클래스 = 하나의 책임 (계산/출력/저장 분리됨) +□ 새 타입 추가 시 기존 함수를 수정하지 않아도 됨 (switch 분기 없음) +□ 자식 클래스 메서드가 throw를 던지지 않음 +□ implements한 인터페이스의 모든 메서드를 실제로 구현함 +□ 고수준 클래스 안에서 new <저수준구현체>() 없음 +``` + +--- + +## Placeholder 치환 가이드 + +| 플레이스홀더 | 의미 | 치환 예시 | +|---|---|---| +| `` | 처리 클래스 | `OrderProcessor`, `Validator` | +| `` | 처리 메서드 | `process`, `validate`, `compute` | +| `` | 데이터 타입 | `int`, `Order`, `Record` | +| `` | 핸들러 인터페이스 | `MessageSender`, `EventHandler` | +| `` | 분기 enum | `MessageType`, `EventType` | +| `` | enum 멤버 | `EMAIL/SMS/PUSH` | +| `` | 베이스 인터페이스 | `Bird`, `Worker`, `Vehicle` | +| `` | 능력 추가 인터페이스 | `FlyingBird`, `EatableWorker` | +| `` | 구현 클래스 | `Sparrow`, `RobotWorker` | +| `` | 의존하는 쪽 | `Computer`, `App` | +| `` | 의존받는 인터페이스 | `InputDevice`, `Logger` | +| `` | 구체 구현체 | `Keyboard`, `FileLogger` | \ No newline at end of file diff --git a/.claude/skills/frontend-react/SKILL.md b/.claude/skills/frontend-react/SKILL.md new file mode 100644 index 0000000..86651c6 --- /dev/null +++ b/.claude/skills/frontend-react/SKILL.md @@ -0,0 +1,189 @@ +--- +name: frontend-react +description: "Use this skill when writing or reviewing React components, Next.js pages, or TypeScript frontend code. Covers component composition rules, props discipline, event handler naming, loading/error states, mock data patterns, and styling conventions. Triggers on React, Next.js, TSX, JSX, component, frontend, Tailwind keywords." +--- + +# React 컴포넌트 규칙 + +## 5개 핵심 규칙 + +``` +1. 컴포넌트 하나당 파일 하나 +2. 데이터를 직접 fetch하지 말 것 — props로만 받기 +3. 버튼 onClick은 반드시 props로 받기 (내부에서 API 호출 금지) +4. useState는 최대한 위로 올리기 (페이지 레벨에서 관리) +5. 파일명 = 컴포넌트명 (Card.tsx, Panel.tsx) +``` + +--- + +## 절대 쓰지 말 것 + +``` +- any 타입 +- useEffect 안에서 fetch +- 컴포넌트 안에서 router.push 직접 호출 +- console.log (디버깅 후 반드시 제거) +- 인라인 스타일 (style={{ }}) — Tailwind 클래스만 +- 파일 안에 타입 직접 선언 (types/index.ts에서 import) +``` + +--- + +## 올바른 컴포넌트 패턴 + +**잘못된 것:** + +```tsx +// Card.tsx +export function Card({ }) { + const [, set] = useState(null); + + useEffect(() => { + fetch(`/api//${}`) // ← API 직접 호출 금지 + .then(r => r.json()) + .then(set); + }, []); + + return ( +
fetch(`/api//${}/`)}> + {?.name} +
+ ); +} +``` + +**올바른 것:** + +```tsx +// Card.tsx — 데이터는 props, 액션은 콜백 +import { } from '@/types'; + +type Props = { + name: string; + status: ['status']; + revision: string; + isLoading: boolean; + error: string | null; + onClick: () => void; // 내부에서 뭘 할지 모름, 위에서 결정 +}; + +export function Card({ name, status, revision, isLoading, error, onClick }: Props) { + if (isLoading) return
로딩 중...
; + if (error) return
{error}
; + + return ( +
+ {name} + + {revision} +
+ ); +} +``` + +API 연결은 페이지 레벨에서만: + +```tsx +// app//page.tsx — API는 여기서만 +const = await fetchList(); + +<Card + name={.name} + status={.status} + revision={`v${.version}`} + isLoading={isLoading} + error={error} + onClick={() => router.push(`//${.id}`)} +/> +``` + +--- + +## 이벤트 핸들러 네이밍 + +``` +컨벤션: on + 명사 + 동사 + +onClick ← 클릭 +onApprove ← 승인 버튼 +onRequestChanges ← 변경 요청 +onCopy ← 복사 버튼 +onResolve ← 댓글 resolve +``` + +--- + +## 로딩/에러 슬롯 — 반드시 포함 + +모든 컴포넌트 props에 아래 두 개 포함: + +```tsx +type Props = { + isLoading: boolean; + error: string | null; + // ... 나머지 +}; + +if (isLoading) return
로딩 중...
; +if (error) return
{error}
; +``` + +--- + +## 목업 데이터 — 파일 하나로 통일 + +```tsx +// mocks/index.ts +import { , , } from '@/types'; + +export const mock: = { + id: '-001', + : '-001', + title: '<예시 제목>', + status: 'in_review', + createdBy: 'user-001', + createdAt: '2026-04-14T10:00:00Z', +}; + +export const mock: = { + id: '-001', + projectId: 'project-001', + title: '<예시 제목>', + assigneeId: 'user-001', + startDate: '2026-04-14', + endDate: '2026-04-27', + status: 'in_progress', +}; +``` + +컴포넌트 안에 하드코딩 금지. 무조건 이 파일에서 import. + +--- + +## 컴포넌트 완성 기준 + +아래 네 개 체크 후 개발자에게 알릴 것: + +``` +□ props 타입 정의됨 (types/index.ts에서 import) +□ 로딩/에러 상태 처리됨 +□ 목업 데이터로 렌더링 확인됨 +□ lint/형식 위반 없음 (any 타입, 인라인 스타일 등) +``` + +--- + +## Placeholder 치환 가이드 + +| 플레이스홀더 | 의미 | 치환 예시 | +|---|---|---| +| `` | 컴포넌트 대상 (PascalCase) | `Order`, `Asset`, `Post` | +| `` | 변수/파일명 (camelCase) | `order`, `asset`, `post` | +| `` | 라우트 경로 (복수형) | `orders`, `assets`, `posts` | +| `` | ID prop 이름 | `orderId`, `assetId` | +| `` | 상위 엔티티 | `Project`, `Workspace` | +| `` | 상위 엔티티 ID | `projectId`, `workspaceId` | +| `` | 연관 엔티티 | `Comment`, `Tag`, `Review` | +| `` | 하위 엔티티 (네이밍용) | `Comment`, `Item` | +| `` | 액션 동사 (PascalCase) | `Submit`, `Cancel`, `Share` | \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..837439f --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +EXPO_PUBLIC_SUPABASE_URL=https://zgjxxldhyhofpmrwdsbh.supabase.co +EXPO_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_oYSlVmJ2B9e6-eHsuXyzrw_Ca79lPnr +# EXPO_PUBLIC_API_URL=http://192.168.168.87:8080 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..d2bf137 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Cross-Review Loop pre-commit hook +# fail-open 원칙: 의존성 없거나 파일 없으면 통과 + +set -uo pipefail + +# 1. .review 디렉토리 없으면 통과 (초기 커밋 / 사이클 시작 전) +[ ! -d ".review" ] && exit 0 + +# 2. 가장 최근 사이클 찾기 +CURRENT_CYCLE=$(ls .review/ 2>/dev/null | sort -V | tail -1) +[ -z "$CURRENT_CYCLE" ] && exit 0 + +# 3. 사이클 횟수 검증 (3회 초과 차단) +CYCLE_COUNT=$(ls .review/ 2>/dev/null | wc -l) +if [ "$CYCLE_COUNT" -gt 3 ]; then + echo "❌ Cross-review 사이클 $CYCLE_COUNT 회 — 한도 초과" + echo " 사용자 결정 필요. 강제 커밋: git commit --no-verify" + exit 1 +fi + +# 4. status.txt 확인 +STATUS_FILE=".review/$CURRENT_CYCLE/status.txt" +[ ! -f "$STATUS_FILE" ] && exit 0 # 파일 없으면 통과 + +STATUS=$(cat "$STATUS_FILE" 2>/dev/null | tr -d '[:space:]') +if [ "$STATUS" != "ready_to_merge" ]; then + echo "❌ 사이클 $CURRENT_CYCLE 상태: '$STATUS'" + echo " 교차 검증 미완료. 'ready_to_merge' 상태에서만 커밋 가능." + echo " 강제 커밋: git commit --no-verify" + exit 1 +fi + +echo "✅ Cross-review 통과 (cycle: $CURRENT_CYCLE)" +exit 0 \ No newline at end of file diff --git a/.github/workflows/cross-review.yml b/.github/workflows/cross-review.yml new file mode 100644 index 0000000..3ba5cea --- /dev/null +++ b/.github/workflows/cross-review.yml @@ -0,0 +1,25 @@ +name: Cross-Model Review +on: + pull_request: + types: [opened, synchronize] + +jobs: + codex-review: + if: contains(github.event.pull_request.labels.*.name, 'claude-generated') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run Codex review against plan.md + run: | + codex review \ + --plan plan.md \ + --diff "$(git diff origin/main)" \ + --output review.md \ + --criteria "Sprint Contract 만족 / 자동 체크 통과 / plan.md 외 변경사항" + - name: Block merge if issues found + run: | + if grep -q "ISSUE FOUND" review.md; then + gh pr comment ${{ github.event.pull_request.number }} \ + --body-file review.md + exit 1 + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index d914c32..7e96104 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.* # generated native folders /ios /android + + +.review/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7b016a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6981540 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# Architecture +Web2, java spring backend - JDK 17, expo,react native frontend,r2 storage,supabase +# Conventions + +# Commands + +# prohibited pattern + +## 금지 - basic +- critical한 파일 임의 수정 금지 + +## 금지 — Backend +- req.body 직접 DB, userId from body +- any 타입, 에러 raw throw + +## 금지 — Frontend +- useEffect 안 fetch, 인라인 스타일 + +# Testing & Verify + +# Past Failures -> Rules +- 실패/의도와 다른 에러 발생 시 여기에 한줄씩 추가 + +## Review Protocol + +- plan.md 작성 시 다음 항목 필수 포함: + 1. 입력/출력 명세 + 2. Sprint Contract — 통과 기준 (테스트 케이스, gas 한도, slither 통과) + 3. 누락된 엣지 케이스 후보 3개 + 4. 더 단순한 대안 1개 + +- Generate 완료 후 별도 세션에서 호출됨. 검증 항목: + 1. plan.md Sprint Contract 만족 여부 (의미 수준) + 2. 자동 체크(test/lint/slither) 통과 여부 + 3. plan.md에 없던 변경사항 식별 + +- issue 발견 시 PR 머지 차단, Claude Code 세션 재개 트리거. + +# Re-review Protocol + +review.md 작성 시 버전 표기 필수: +- 첫 검증: review.md (Pass 1) +- 재검증: review.md에 "## Re-review" 섹션 append (Pass 3) + +재검증 항목: +1. 이전 issue가 RESOLVED 표기되어 있고 실제로 수정됐는지 확인 +2. 수정 과정에서 새로 도입된 회귀 이슈 식별 +3. plan.md Sprint Contract 만족 여부 재확인 + +재검증에서 issue 발견 시 "REGRESSION" 또는 "UNRESOLVED" 라벨 명시. \ No newline at end of file diff --git a/AUTH_INTEGRATION_CHECKLIST.md b/AUTH_INTEGRATION_CHECKLIST.md new file mode 100644 index 0000000..e0a6166 --- /dev/null +++ b/AUTH_INTEGRATION_CHECKLIST.md @@ -0,0 +1,336 @@ +# Auth Integration Checklist + +이 문서는 `MainFE`, `MainAPP`, `MainBE`가 각각 독립 클라이언트/백엔드로 동작하면서, 공통으로 Supabase OAuth 소셜 로그인을 지원하도록 작업을 쪼갠 체크리스트다. + +## 1. 현재 상태 요약 + +### MainBE +- 공통 인증 백엔드 역할 +- `POST /api/v1/auth/supabase` 구현됨 +- Supabase access token 검증 후 자체 JWT 발급 구현됨 +- refresh API 구현됨 + +### MainAPP +- 모바일 앱 클라이언트 역할 +- Supabase OAuth 로그인 구현 흔적 있음 +- 홈, 로그, 피드 등 일부 실제 화면과 API 연동 있음 +- 일부 화면은 플레이스홀더 +- 서비스 JWT 저장 및 API 인증 부착은 미완성 + +### MainFE +- 웹 클라이언트 역할 +- 현재 기준 Supabase OAuth 로그인 구현 미확인 +- 웹용 callback 처리, 서비스 JWT 저장, 보호 라우트 작업 필요 + +## 2. 목표 상태 + +- `MainAPP`: 모바일 클라이언트로서 주요 기능 완성 + Supabase OAuth 로그인 완성 +- `MainFE`: 웹 클라이언트로서 주요 기능 완성 + Supabase OAuth 로그인 완성 +- `MainBE`: 웹/앱 공통 인증 백엔드로 유지 +- 공통 인증 흐름: + 1. 클라이언트가 Supabase OAuth 시작 + 2. 클라이언트가 Supabase access token 획득 + 3. 클라이언트가 `MainBE /api/v1/auth/supabase` 호출 + 4. `MainBE`가 자체 access/refresh token 발급 + 5. 클라이언트가 서비스 토큰 저장 후 이후 API 호출에 사용 + +## 3. 공통 선행 작업 + +### A. 인증 계약 고정 +- [ ] `POST /api/v1/auth/supabase` 요청 바디 최종 확정 +- [ ] 응답 필드 최종 확정 + - [ ] `accessToken` + - [ ] `refreshToken` + - [ ] `userId` + - [ ] `nickname` + - [ ] `isNewUser` +- [ ] `POST /api/v1/auth/refresh` 호출 규칙 확정 +- [ ] 401, 403, refresh 실패 시 공통 동작 정의 +- [ ] 신규 유저와 기존 유저 분기 기준 문서화 +- [ ] 로그아웃 정책 정의 + - [ ] 로컬 토큰 삭제 + - [ ] Supabase 세션 정리 여부 + - [ ] 백엔드 refresh token revoke 여부 + +### B. Supabase 콘솔 설정 +- [ ] Google provider 설정 확인 +- [ ] Kakao provider 설정 확인 +- [ ] 웹 redirect URL 등록 +- [ ] 앱 deep link redirect URL 등록 +- [ ] 개발/운영 redirect URL 분리 +- [ ] 동일 Supabase 프로젝트를 `MainFE`, `MainAPP`, `MainBE`가 쓰는지 확인 + +### C. 환경변수 정리 +- [ ] `MainFE` env 키 이름 정리 +- [ ] `MainAPP` env 키 이름 정리 +- [ ] `MainBE` env 키 이름 정리 +- [ ] 임시 fallback 값 제거 +- [ ] 개발/운영 env 분리 + +## 4. MainBE 작업 체크리스트 + +### BE-1. 인증 스펙 안정화 +- [ ] `AuthController` 응답 구조 재검토 +- [ ] Swagger 또는 문서에 소셜 로그인 플로우 명시 +- [ ] `SupabaseLoginRequest` 유효성 검증 재확인 +- [ ] `AnonymousRegistrationResponse` 명명 검토 + - [ ] 소셜 로그인 응답용 이름이 맞는지 확인 + - [ ] 필요 시 별도 DTO로 분리 + +### BE-2. 토큰 정책 확정 +- [ ] access token 만료 시간 확인 +- [ ] refresh token 만료 시간 확인 +- [ ] refresh token 재발급 정책 확인 +- [ ] 디바이스별 refresh token 저장 정책 확인 +- [ ] 다중 로그인 허용 범위 확인 + +### BE-3. 소셜 로그인 유저 처리 검증 +- [ ] 신규 유저 생성 규칙 확인 +- [ ] 기존 유저 매핑 규칙 확인 +- [ ] provider/sub 기반 유저 매핑 테스트 +- [ ] 닉네임 생성 규칙 검증 +- [ ] 프로필 이미지 동기화 규칙 검증 +- [ ] 이메일 없는 provider 케이스 처리 확인 + +### BE-4. 인증 보조 API 보완 +- [ ] 로그아웃 API 필요 여부 결정 +- [ ] 전체 로그아웃 API 필요 여부 결정 +- [ ] 현재 사용자 정보 조회 API 필요 여부 결정 +- [ ] 앱/웹 초기 진입용 `me` API 필요 여부 결정 + +### BE-5. 운영 안정성 +- [ ] 인증 관련 예외 메시지 정리 +- [ ] 토큰/개인정보 로그 노출 여부 점검 +- [ ] CORS 설정 검토 +- [ ] `Authorization` 헤더 노출/허용 점검 +- [ ] 인증 실패 모니터링 포인트 정리 + +### BE-6. 테스트 +- [ ] Google 기존 회원 로그인 테스트 +- [ ] Google 신규 회원 로그인 테스트 +- [ ] Kakao 기존 회원 로그인 테스트 +- [ ] Kakao 신규 회원 로그인 테스트 +- [ ] 잘못된 Supabase token 테스트 +- [ ] refresh token 재발급 테스트 +- [ ] deviceId mismatch 테스트 + +## 5. MainAPP 작업 체크리스트 + +### APP-1. 로그인 완료 처리 +- [ ] OAuth 성공 후 백엔드 응답을 토큰 스토어에 저장 +- [ ] 저장 대상 필드 확정 + - [ ] `accessToken` + - [ ] `refreshToken` + - [ ] `userId` + - [ ] 필요 시 `nickname` +- [ ] `isNewUser`에 따른 분기 처리 추가 + +### APP-2. 인증 상태 기반 진입 제어 +- [ ] 앱 시작 시 hydration 완료 전 로딩 처리 +- [ ] 저장된 토큰 존재 시 자동 로그인 분기 +- [ ] 미로그인 시 온보딩/로그인 진입 +- [ ] 로그인 완료 시 `Main` 진입 +- [ ] 로그아웃 시 인증 화면으로 복귀 + +### APP-3. API 인터셉터 완성 +- [ ] `axios instance`에 access token 부착 활성화 +- [ ] 401 감지 시 refresh 시도 로직 추가 +- [ ] refresh 성공 시 원 요청 재시도 +- [ ] refresh 실패 시 토큰 삭제 +- [ ] 중복 refresh 요청 방지 처리 + +### APP-4. 로그아웃 처리 +- [ ] 로컬 토큰 삭제 구현 +- [ ] Supabase local session 정리 구현 +- [ ] 설정 화면 또는 옵션 화면에 로그아웃 연결 + +### APP-5. 인증 이후 기능 연결 +- [ ] 홈 API가 서비스 JWT로 정상 동작하는지 확인 +- [ ] 로그 API가 서비스 JWT로 정상 동작하는지 확인 +- [ ] 피드 API가 서비스 JWT로 정상 동작하는지 확인 +- [ ] 댓글 API가 서비스 JWT로 정상 동작하는지 확인 +- [ ] 상세 화면 API가 서비스 JWT로 정상 동작하는지 확인 + +### APP-6. 플레이스홀더 제거 우선순위 정리 +- [ ] 프로필 화면 구현 +- [ ] 팔로우 화면 구현 +- [ ] 로그 상세 화면 구현 +- [ ] 배송 화면 구현 +- [ ] 정원 잠금 해제 화면 구현 +- [ ] 식물 등록 플로우 구현 +- [ ] 데일리 미션 화면 구현 + +### APP-7. UX 보완 +- [ ] 로그인 중 로딩 표시 개선 +- [ ] 로그인 실패 메시지 처리 +- [ ] OAuth 취소 시 UX 처리 +- [ ] 신규 유저 온보딩 후속 플로우 설계 + +### APP-8. 테스트 +- [ ] 앱 cold start 자동 로그인 테스트 +- [ ] 앱 재실행 후 세션 유지 테스트 +- [ ] access token 만료 후 refresh 테스트 +- [ ] refresh 만료 후 로그아웃 테스트 +- [ ] Google 로그인 테스트 +- [ ] Kakao 로그인 테스트 +- [ ] 비회원 등록 플로우 테스트 + +## 6. MainFE 작업 체크리스트 + +### FE-1. 인증 구조 설계 +- [ ] 웹에서 사용할 상태관리 방식 결정 + - [ ] Context + - [ ] Zustand + - [ ] React Query + store 혼합 +- [ ] 토큰 저장 위치 결정 + - [ ] 메모리 + - [ ] localStorage + - [ ] secure cookie 사용 여부 검토 +- [ ] 보호 라우트 방식 결정 + +### FE-2. Supabase 웹 클라이언트 추가 +- [ ] Supabase client 파일 생성 +- [ ] env 연결 +- [ ] provider 목록 정의 +- [ ] auth 유틸 분리 + +### FE-3. 웹 OAuth 시작 구현 +- [ ] 로그인 화면 또는 진입 버튼 배치 +- [ ] Google 로그인 버튼 구현 +- [ ] Kakao 로그인 버튼 구현 +- [ ] `supabase.auth.signInWithOAuth()` 연결 +- [ ] 웹 redirect URL 적용 + +### FE-4. callback 처리 구현 +- [ ] 웹 callback 라우트 생성 +- [ ] callback 페이지 로딩 상태 추가 +- [ ] Supabase에서 access token 또는 code 처리 +- [ ] 토큰 추출 실패 처리 +- [ ] 중복 호출 방지 처리 + +### FE-5. 백엔드 토큰 교환 구현 +- [ ] `MainBE /api/v1/auth/supabase` 호출 API 추가 +- [ ] 백엔드 응답을 웹 스토어에 저장 +- [ ] `isNewUser` 분기 처리 +- [ ] 로그인 성공 후 리다이렉트 처리 + +### FE-6. 웹 API 인증 연결 +- [ ] axios/fetch 인터셉터에 service access token 부착 +- [ ] 401 시 refresh 호출 구현 +- [ ] refresh 성공 시 재시도 +- [ ] refresh 실패 시 로그인 화면 복귀 + +### FE-7. 라우팅 및 화면 보호 +- [ ] 로그인 필요 페이지 목록 정리 +- [ ] 보호 라우트 적용 +- [ ] 로그인 페이지 접근 제한 처리 +- [ ] 새로고침 시 로그인 상태 복원 +- [ ] 원래 가려던 페이지로 복귀 처리 + +### FE-8. 로그아웃 처리 +- [ ] 서비스 토큰 삭제 +- [ ] Supabase 세션 정리 +- [ ] 로그아웃 버튼 연결 +- [ ] 로그아웃 후 public route 이동 + +### FE-9. 기능 통합 +- [ ] 인증 필요한 기존 API 호출부 점검 +- [ ] 유저 상태 의존 화면 점검 +- [ ] 피드/로그/댓글/프로필 기능을 인증 구조와 연결 +- [ ] 신규 유저 후속 플로우 연결 + +### FE-10. 테스트 +- [ ] 브라우저 새로고침 후 세션 유지 테스트 +- [ ] Google 로그인 테스트 +- [ ] Kakao 로그인 테스트 +- [ ] OAuth 취소 테스트 +- [ ] access token 만료 후 refresh 테스트 +- [ ] refresh 만료 후 로그아웃 테스트 +- [ ] 보호 라우트 접근 테스트 + +## 7. 권장 구현 순서 + +### Phase 1. 공통 계약 정리 +- [ ] BE 응답/refresh/logout 정책 확정 +- [ ] Supabase redirect/env 설정 확정 + +### Phase 2. MainAPP 인증 완성 +- [ ] 로그인 성공 후 서비스 JWT 저장 +- [ ] 인터셉터/refresh/로그아웃 완성 +- [ ] 인증 상태 기반 진입 제어 완성 + +### Phase 3. MainFE 인증 신규 구축 +- [ ] Supabase 웹 로그인 구현 +- [ ] callback 구현 +- [ ] 백엔드 토큰 교환 구현 +- [ ] 보호 라우트/refresh 완성 + +### Phase 4. 기능 마무리 +- [ ] MainAPP 플레이스홀더 제거 +- [ ] MainFE 인증 연동 누락 화면 보완 +- [ ] 웹/앱 통합 QA 수행 + +## 8. 이슈 단위 추천 분해 + +### Epic A. 공통 인증 규격 +- [ ] A-1. 인증 API 응답 스펙 확정 +- [ ] A-2. refresh 정책 확정 +- [ ] A-3. logout 정책 확정 +- [ ] A-4. Supabase provider/redirect 설정 완료 + +### Epic B. MainBE 인증 안정화 +- [ ] B-1. DTO/명명 정리 +- [ ] B-2. 인증 예외/로그 정리 +- [ ] B-3. 로그아웃 및 `me` API 필요 여부 정리 +- [ ] B-4. 소셜 로그인 테스트 케이스 작성 + +### Epic C. MainAPP 인증 완성 +- [ ] C-1. 로그인 후 서비스 토큰 저장 +- [ ] C-2. Authorization 인터셉터 활성화 +- [ ] C-3. refresh 재시도 구현 +- [ ] C-4. 앱 시작 시 인증 상태 복원 +- [ ] C-5. 로그아웃 구현 + +### Epic D. MainAPP 기능 완성 +- [ ] D-1. 프로필/팔로우 화면 구현 +- [ ] D-2. 로그 상세/배송 화면 구현 +- [ ] D-3. 식물 등록 플로우 구현 +- [ ] D-4. 데일리 미션 화면 구현 + +### Epic E. MainFE 인증 구축 +- [ ] E-1. Supabase 웹 클라이언트 추가 +- [ ] E-2. 로그인 버튼/UI 추가 +- [ ] E-3. callback 페이지 구현 +- [ ] E-4. 백엔드 토큰 교환 및 저장 +- [ ] E-5. 보호 라우트/refresh 구현 +- [ ] E-6. 로그아웃 구현 + +### Epic F. MainFE 기능 통합 +- [ ] F-1. 기존 페이지 인증 연결 +- [ ] F-2. 신규 유저 분기 연결 +- [ ] F-3. 새로고침/복귀 UX 정리 +- [ ] F-4. 브라우저 QA + +## 9. 완료 기준 + +### MainAPP 완료 기준 +- [ ] Google/Kakao 로그인 가능 +- [ ] 로그인 후 서비스 API 정상 호출 +- [ ] 앱 재실행 후 세션 복원 +- [ ] 만료 후 refresh 동작 +- [ ] 로그아웃 가능 +- [ ] 주요 플레이스홀더 제거 + +### MainFE 완료 기준 +- [ ] Google/Kakao 로그인 가능 +- [ ] callback 처리 안정화 +- [ ] 로그인 후 서비스 API 정상 호출 +- [ ] 새로고침 후 세션 복원 +- [ ] 보호 라우트 동작 +- [ ] 로그아웃 가능 + +### MainBE 완료 기준 +- [ ] 웹/앱 모두 같은 인증 API로 로그인 가능 +- [ ] 신규/기존 유저 처리 안정화 +- [ ] refresh 정책 검증 완료 +- [ ] 인증 예외와 보안 로그 정리 완료 diff --git a/App.tsx b/App.tsx index df060c6..36f7a90 100644 --- a/App.tsx +++ b/App.tsx @@ -1,19 +1,108 @@ -import "./global.css"; +import "./global.css"; +import { useEffect, useRef } from "react"; import { StatusBar } from "expo-status-bar"; -import { NavigationContainer } from "@react-navigation/native"; -import { SafeAreaProvider } from "react-native-safe-area-context"; +import { + NavigationContainer, + useNavigationContainerRef, +} from "@react-navigation/native"; +import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { RootNavigator } from "@/navigation"; import QueryProvider from "@/providers/QueryProvider"; +import StatusView from "@/components/common/StatusView"; +import { + SUPABASE_CONFIG_ERROR_MESSAGE, + isSupabaseConfigured, +} from "@/apis/supabase"; +import { useNotificationSettings } from "@/hooks/option/useNotificationApi"; +import { debugLog } from "@/utils/debug"; +import useTokenStore from "@/stores/useTokenStore"; +import { registerDeviceFcmToken, unregisterDeviceFcmToken } from "@/utils/fcm"; + +function NotificationTokenSync() { + const { accessToken } = useTokenStore(); + const { data: notificationSettings } = useNotificationSettings(); + const lastSyncedStateRef = useRef(null); + + useEffect(() => { + if (!accessToken || !notificationSettings) { + lastSyncedStateRef.current = null; + return; + } + + const syncKey = `${accessToken}:${notificationSettings.notificationEnabled}`; + if (lastSyncedStateRef.current === syncKey) { + return; + } + + lastSyncedStateRef.current = syncKey; + + if (notificationSettings.notificationEnabled) { + void registerDeviceFcmToken(); + return; + } + + void unregisterDeviceFcmToken(); + }, [accessToken, notificationSettings]); + + return null; +} export default function App() { + const navigationRef = useNavigationContainerRef(); + const currentRouteNameRef = useRef(undefined); + + useEffect(() => { + debugLog("App", "App mounted", { isSupabaseConfigured }); + }, []); + + if (!isSupabaseConfigured) { + return ( + + + + + + ); + } + return ( - - - - - - - - + + + + + + { + const routeName = navigationRef.getCurrentRoute()?.name; + currentRouteNameRef.current = routeName; + debugLog("Navigation", "ready", { routeName }); + }} + onStateChange={() => { + const previousRouteName = currentRouteNameRef.current; + const nextRouteName = navigationRef.getCurrentRoute()?.name; + + if (previousRouteName !== nextRouteName) { + debugLog("Navigation", "route changed", { + from: previousRouteName, + to: nextRouteName, + }); + } + + currentRouteNameRef.current = nextRouteName; + }} + > + + + + + + + ); } diff --git a/FEED_INFINITE_SCROLL_PLAN.md b/FEED_INFINITE_SCROLL_PLAN.md new file mode 100644 index 0000000..3c87ae0 --- /dev/null +++ b/FEED_INFINITE_SCROLL_PLAN.md @@ -0,0 +1,66 @@ +# Feed 상세 무한 스크롤 구현 계획 + +## 목표 + +- 둘러보기에서 선택한 포스트를 상세 진입 시 첫 카드로 고정한다. +- 첫 카드 아래로는 랜덤 포스트를 세션 기반으로 이어 붙여 인스타형 세로 스크롤 경험을 만든다. +- 사용자가 다시 맨 위로 올리면 처음 선택한 포스트가 항상 첫 카드에 남아 있어야 한다. + +## 현재 상태 + +- MainAPP 상세 화면은 선택한 포스트 1개만 `ScrollView`로 보여준다. +- MainBE는 랜덤 피드 세션 API를 제공하지만, seed 포스트를 첫 카드로 고정하는 기능은 없다. +- 따라서 MainAPP에서 `선택 포스트 고정 + 랜덤 세션 아이템 후행 배치`를 직접 조합해야 한다. + +## 구현 원칙 + +- 첫 카드 고정은 프론트에서 책임진다. +- 랜덤 피드는 MainBE의 `/api/v1/feed/random/session`, `/api/v1/feed/random/next`를 사용한다. +- 랜덤 세션 응답은 상세 데이터가 아니므로, 각 아이템은 타입별 상세 API로 보강 조회한다. +- seed 포스트와 중복되는 랜덤 아이템은 클라이언트에서 제거한다. + +## 단계별 작업 + +### 1단계. 타입과 API 훅 추가 + +- 랜덤 피드 세션 응답 타입 추가 +- 세션 시작/다음 페이지 API 추가 +- `useInfiniteQuery` 기반 랜덤 세션 훅 추가 + +### 2단계. 상세 화면 공통 리스트 구조 전환 + +- `ScrollView` 기반 상세 화면을 `FlatList` 기반으로 전환 +- 첫 item은 seed 포스트로 고정 +- 이후 item은 랜덤 세션 아이템을 이어 붙임 + +### 3단계. 카드 렌더링 분리 + +- 기존 `FeedDetail`은 상세 표시 블록으로 유지 +- seed 포스트는 기존 상세 API 결과를 바로 사용 +- 랜덤 포스트는 타입별 상세 API를 내부에서 조회해 카드 렌더링 + +### 4단계. 댓글 입력 UX 정리 + +- 하단 고정 `CommentComposer`는 seed 포스트에만 붙인다 +- 랜덤 카드에는 댓글 입력을 붙이지 않는다 +- seed 포스트의 기존 댓글 작성 기능은 유지한다 + +### 5단계. 중복 제거와 로딩 처리 + +- seed 포스트와 같은 `(postType, postId)` 랜덤 아이템 제거 +- 페이지 간 중복 방지 +- 랜덤 카드 상세 로딩/에러 상태 표시 +- 다음 페이지 자동 로딩 시 과도한 중복 호출 방지 + +### 6단계. 검증 + +- 둘러보기에서 일기 선택 시 seed 일기 고정 확인 +- 둘러보기에서 아바타 포스트 선택 시 seed 아바타 포스트 고정 확인 +- 아래로 내리면 랜덤 카드가 이어지는지 확인 +- 위로 다시 올리면 처음 선택한 포스트가 첫 카드인지 확인 +- seed 포스트 댓글 작성 후 refetch 정상 반영 확인 + +## 주의사항 + +- 백엔드 랜덤 세션 시작 API는 seed 제외를 받지 않으므로, 첫 페이지에서 seed 중복이 내려와도 프론트에서 제거해야 한다. +- 랜덤 카드가 많아질수록 상세 API 호출 수가 늘어나므로 한 페이지 크기는 과하게 크게 잡지 않는다. diff --git a/GUEST_TO_SOCIAL_LINKING_PLAN.md b/GUEST_TO_SOCIAL_LINKING_PLAN.md new file mode 100644 index 0000000..db8e4f0 --- /dev/null +++ b/GUEST_TO_SOCIAL_LINKING_PLAN.md @@ -0,0 +1,125 @@ +# Guest To Social Linking Plan + +## 목표 + +- 현재의 `비회원으로 화단 만들기` 진입 장점을 유지한다. +- 사용자가 식물, 방명록, 피드 등을 써본 뒤 원할 때 소셜 계정으로 연동할 수 있게 한다. +- 장기적으로는 계정 복구와 다기기 사용성을 확보한다. + +## 현재 판단 + +### 지금 UX 상태 + +- 비회원 시작도 초반에 닉네임을 입력한다. +- 소셜 신규 가입도 초반에 닉네임을 입력한다. +- 그래서 둘의 초기 마찰은 현재 거의 비슷하다. + +### 바로 비회원 가입을 없애지 않는 이유 + +- 첫 진입 장벽은 비회원이 가장 낮다. +- 소셜 로그인 강제는 첫 사용 이탈을 늘릴 가능성이 있다. +- 서비스 특성상 먼저 써보고 나중에 계정 보존 필요성을 느끼게 만드는 쪽이 자연스럽다. + +## 추천 방향 + +### 단기 + +- 비회원 가입 유지 +- 소셜 신규 가입도 유지 +- 설정 화면 또는 특정 시점에서 `소셜 계정 연동` 진입점 제공 + +### 중기 + +- 비회원 유저가 식물 등록, 방명록 작성, 피드 참여 등 핵심 행동 후 + 계정 연동의 장점을 설명하며 연동을 유도 +- 예시 문구: + - 기기 변경 시 기록을 보존할 수 있어요 + - 방명록과 식물 기록을 안전하게 저장할 수 있어요 + +### 장기 + +- 연동률과 이탈률을 보고 비회원 축소 여부 판단 +- 실제 데이터상 문제 없을 때만 소셜 우선 정책 검토 + +## MainAPP 변경 포인트 + +### 1. 설정 화면에 소셜 계정 연동 메뉴 추가 + +- 위치: `OptionScreen` +- 목적: 언제든 사용자가 계정을 승격할 수 있게 함 + +### 2. 연동 전용 화면 또는 바텀시트 추가 + +- 선택지: + - 구글 연동 + - 카카오 연동 +- 주의: + - 이미 다른 계정에 연결된 소셜 계정인지 에러 처리 필요 + +### 3. 연동 유도 진입 타이밍 설계 + +- 예시: + - 첫 방명록 작성 직후 + - 첫 아바타 등록 완료 후 + - 설정 화면 상단 배너 + +### 4. 연동 완료 후 사용자 안내 + +- 문구 예시: + - 이제 소셜 계정으로 로그인해도 현재 텃밭을 그대로 이어서 사용할 수 있어요. + +## MainBE 변경 포인트 + +### 1. 비회원 계정과 소셜 계정 연결 API 추가 + +- 예시 엔드포인트: + - `POST /api/v1/auth/link-social` +- 입력값 예시: + - Supabase access token +- 처리 내용: + - 현재 로그인된 비회원 사용자와 소셜 계정을 연결 + - 이미 다른 유저에 연결된 소셜 계정이면 차단 + +### 2. User 엔티티에 계정 상태 구분 정리 + +- 현재는 비회원/소셜 구분이 명확하지 않을 수 있음 +- 최소한 아래 식별값이 필요함 + - anonymous/guest 여부 + - oauthProvider + - oauthSubject + - linkedAt + +### 3. 중복 계정 충돌 처리 + +- 같은 소셜 계정이 이미 다른 사용자에 연결된 경우 +- 현재 게스트 계정 데이터와 병합할지, 차단할지 정책 필요 +- 추천은 초기엔 `병합 금지, 연결 차단`이다 + +### 4. 로그인 응답 구조 정리 + +- 연동된 계정인지 +- 신규 소셜 가입인지 +- 기존 연동 계정 로그인인지 +- 프론트가 명확히 분기할 수 있게 응답값 보강 필요 + +## 권장 정책 + +### 초기 정책 + +- 비회원 -> 소셜 연동: 허용 +- 이미 독립적으로 생성된 다른 소셜 계정과 데이터 병합: 금지 +- 충돌 시 사용자에게 새 계정 생성 대신 기존 계정이 존재한다고 안내 + +### 이유 + +- 병합 로직은 방명록, 피드, 정원, 아바타, 알림, 팔로우 등 전체 도메인을 건드려 리스크가 크다. +- 먼저 `단일 사용자에 소셜 식별자 연결`까지만 구현하는 것이 안전하다. + +## 결론 + +- 지금은 `비회원 가입 제거`보다 `비회원 유지 + 나중에 소셜 연동`이 더 적절하다. +- 구현 우선순위는 + 1. 설정의 소셜 연동 진입점 + 2. MainBE 계정 연결 API + 3. 충돌 처리 정책 + 순서가 맞다. diff --git a/HOME_MIGRATION_CHECKLIST.md b/HOME_MIGRATION_CHECKLIST.md new file mode 100644 index 0000000..2ca3937 --- /dev/null +++ b/HOME_MIGRATION_CHECKLIST.md @@ -0,0 +1,57 @@ +# Home Migration Checklist + +`MainFE -> MainAPP` 홈 디자인 이식 진행 체크리스트 + +## 1. 현재 상태 점검 + +- [x] `MainFE` 홈 구조 확인 +- [x] `MainAPP` 홈 구현 범위 확인 +- [x] 누락된 API/모달/상호작용 포인트 식별 +- [x] 현재 디자인 변경 파일 범위 고정 + +## 2. 홈 화면 구조 정리 + +- [x] `PagerView` 기반 정원 슬롯 구조 반영 +- [x] 정원 잠금/빈 슬롯/아바타 상태 분기 반영 +- [x] 하단 미션 시트 1차 UI 반영 +- [x] `MainFE` 식물별 전용 화면 구조를 RN 컴포넌트로 분리 +- [x] 홈 공용 컴포넌트 디렉터리 `src/components/home/*` 정리 + +## 3. 홈 상호작용 연결 + +- [x] 지도 버튼 모달 연결 +- [x] 트래킹 모달 RN 버전 이식 +- [x] 감정 체크 모달 RN 버전 이식 +- [x] 물 주기 액션 연결 +- [x] 햇빛 주기 액션 연결 + +## 4. 데이터/API 연결 + +- [x] `/api/v1/home` 요약 데이터 연결 +- [x] `panel` API 존재 여부 재확인 +- [x] `panel` API 훅/타입 추가 +- [x] 하단 시트 데이터 소스를 `panel` 기준으로 분리 +- [ ] 물/햇빛 액션 API 계약 상세 확인 + +## 5. 홈 UI 마감 + +- [ ] 페이지 인디케이터 디테일 조정 +- [ ] 배경/텍스트/버블 간격 미세 조정 +- [ ] 바텀시트 열림/닫힘 인터랙션 개선 +- [ ] 빈 슬롯 CTA 문구/동선 확정 +- [ ] 잠금 슬롯 CTA 문구/비활성 상태 확정 + +## 6. 연계 화면 점검 + +- [x] `Feed` 헤더 정렬 보정 +- [x] `Follow` 목록 스타일 1차 보정 +- [x] `Option` 리스트형 레이아웃 1차 보정 +- [ ] `Feed/Follow/Option`을 홈 비주얼 톤과 다시 맞추기 + +## 7. 검증 및 정리 + +- [x] 타입체크 통과 +- [ ] 실기기/에뮬레이터에서 홈 스와이프 확인 +- [ ] 안전영역/작은 화면 레이아웃 확인 +- [ ] Android 이미지 로딩/기본 이미지 fallback 확인 +- [x] 남은 TODO를 `MAINAPP_MIGRATION_TODO.md`와 동기화 diff --git a/MAINAPP_MIGRATION_TODO.md b/MAINAPP_MIGRATION_TODO.md new file mode 100644 index 0000000..29908a8 --- /dev/null +++ b/MAINAPP_MIGRATION_TODO.md @@ -0,0 +1,387 @@ +# MainAPP Migration TODO + +기준 문서: +- `C:/MainFE/AUTH_INTEGRATION_CHECKLIST.md` +- `C:/MainFE/MAINAPP_MIGRATION_MATRIX.md` + +문서 목적: +- `MainFE` 종료를 전제로 `MainAPP` 단독 운영에 필요한 기능 이식 범위를 앱 레포 내부 기준으로 고정한다. +- `MainAPP`와 `MainBE`의 현재 구현 상태를 MainFE 종료 이후 운영 기준(source of truth)으로 삼는다. +- 현재 `MainAPP`의 구현 상태를 `완료`, `부분구현`, `미구현`으로 나눠 기록한다. +- 남은 TODO와 정책 미확정 항목을 운영 보완 관점에서 정리한다. + +제외 범위: +- 웹 라우팅, 웹 레이아웃, 웹 CSS, 브라우저 전용 UX는 이식 대상으로 보지 않는다. +- 이번 문서는 기능 구현이 아니라 인벤토리와 작업 기준 문서화만 다룬다. + +## 1. 현재 MainAPP 상태 요약 + +### 전체 상태 +- 인증 기반은 완료 상태다. +- 앱 시작 시 저장된 토큰 기준으로 `Main` 또는 `Onboarding`으로 초기 진입이 분기된다. +- 서비스 API 인증 헤더 자동 부착과 refresh 재시도 기반이 있다. +- 실제 사용자 기능 중 현재 의미 있게 연결된 영역은 `홈`, `피드`, `피드 상세`, `댓글 작성`, `로그 캘린더`, `월별 일기 목록`, `로그 상세`, `프로필`, `팔로우 목록`, `배송 신청`, `정원 해금`, `등록 플로우`, `데일리 미션(일기/퀴즈/오늘의 질문)`이다. +- `설정`은 로그아웃과 계정 상태 확인이 가능한 최소 운영 수준으로 정리되었다. + +### 네비게이션 기준 상태 +- 메인 탭: `Home`, `Log`, `Feed`, `Option` +- 인증 스택: `Onboarding`, `Register` +- 상세 스택 중 실제 연결됨: `FeedDiary`, `FeedAvatar`, `LogDetail`, `Profile`, `Follow`, `Delivery`, `DeliveryComplete`, `UnlockGarden`, `RegistrationAvatar`, `RegistrationCreationDetail`, `RegistrationSelectionDetail`, `RegistrationPlantNickname`, `DailyMissionWriteDiary`, `DailyMissionQuizMultipleChoice`, `DailyMissionQuizOx`, `DailyMissionChecking` +- 상세 스택 중 플레이스홀더: 없음 + +### 구현 분류 요약 +- 완료 + - 인증 기반 + - 온보딩/스플래시 + - OAuth 로그인 + - 피드 목록 API 연동 + - 피드 상세 조회 API 연동 + - 댓글 작성 API 연동 + - 로그 캘린더 API 연동 + - 월별 일기 목록 API 연동 + - 오늘의 질문 미션 +- 부분구현 + - 비회원 등록 + - 홈 메인 마감 + - 로그 화면 일부 + - 피드 상세 UX + - 프로필 + - 팔로우 + - 배송 + - 등록 플로우 + - 데일리 미션 일기 작성 + - 설정/운영 안내 + +## 2. 구현된 기능 목록 + +### 완료 + +| 기능명 | 현재 MainAPP 상태 | 근거 파일 | +|---|---|---| +| 인증 기반 | OAuth 이후 서비스 JWT 저장, 앱 재실행 후 세션 복원, Authorization 자동 부착, refresh 1회 재시도, 로그아웃 기반까지 구현됨 | `src/hooks/auth/useSupabaseOAuth.ts`, `src/hooks/auth/useBackendLogin.ts`, `src/stores/useTokenStore.ts`, `src/apis/instance.ts`, `src/utils/auth.ts`, `src/navigation/RootNavigator.tsx` | +| 온보딩/스플래시 | 온보딩 슬라이드와 OAuth 시작 UI가 있음. 앱 시작 시 splash 처리 포함 | `src/pages/onboarding/OnboardingScreen.tsx`, `src/components/common/Splash.tsx` | +| 피드 목록 | 목록 조회 API와 그리드 렌더링, 게시글 타입별 상세 진입이 동작함 | `src/pages/feed/FeedScreen.tsx`, `src/components/feed/FeedList.tsx`, `src/hooks/feed/useFeedApi.ts`, `src/apis/feed/feedApi.ts` | +| 피드 상세 조회 | 일기형/아바타형 상세 조회, 상태별 로딩/에러/빈 댓글 처리, 댓글 입력 흐름이 구현됨 | `src/pages/feed/FeedDiaryScreen.tsx`, `src/pages/feed/FeedAvatarScreen.tsx`, `src/components/feed/FeedDetail.tsx`, `src/components/common/CommentComposer.tsx`, `src/components/common/ScreenHeader.tsx`, `src/components/common/StatusView.tsx`, `src/hooks/log/useDiaryDetailApi.ts`, `src/hooks/feed/useAvatarPostDetailApi.ts` | +| 댓글 작성 | 상세 화면에서 댓글 등록 후 refetch까지 연결됨 | `src/hooks/comments/useCommentApi.ts`, `src/apis/comments/commentApi.ts`, `src/components/common/Comment.tsx` | +| 로그 캘린더 | 월 이동, 캘린더 조회, 일자별 미션 완료 아이콘 렌더링이 구현됨 | `src/pages/log/LogScreen.tsx`, `src/components/log/LogCalendar.tsx`, `src/hooks/log/useCalendarApi.ts`, `src/apis/log/calendarApi.ts` | +| 월별 일기 목록 | 월 이동, 일기 썸네일 목록 조회와 상세 진입 이벤트가 구현됨 | `src/pages/log/LogScreen.tsx`, `src/components/log/MyDiary.tsx`, `src/hooks/log/useDiariesApi.ts`, `src/apis/log/diariesApi.ts` | +| 로그 상세 | `LogDetail` 실제 화면, 댓글 입력, diary detail API 기반 상세 렌더링이 구현됨 | `src/pages/log/LogDetailScreen.tsx`, `src/components/log/MyDiaryDetail.tsx`, `src/components/common/CommentComposer.tsx`, `src/hooks/log/useDiaryDetailApi.ts`, `src/apis/log/diaryDetailApi.ts` | +| 프로필 조회 | 사용자 기본 정보 조회, 대표 정원 정보, 친구 물주기, 팔로우/언팔로우 버튼이 구현됨 | `src/pages/profile/ProfileScreen.tsx`, `src/components/profile/ProfileDetail.tsx`, `src/hooks/profile/useProfileApi.ts`, `src/apis/profile/profileApi.ts`, `src/hooks/follow/useFollowApi.ts` | +| 팔로우 목록 | 팔로잉/팔로워 목록 조회, 탭 전환, 팔로잉 탭 언팔로우, 프로필 이동이 구현됨 | `src/pages/follow/FollowScreen.tsx`, `src/components/follow/UserCard.tsx`, `src/hooks/follow/useFollowApi.ts`, `src/apis/follow/followApi.ts` | +| 정원 해금 | `POST /api/v1/gardens/unlock` body 없는 호출과 홈 재조회가 연결됨 | `src/pages/delivery/UnlockGardenScreen.tsx`, `src/hooks/delivery/useDeliveryApi.ts`, `src/apis/delivery/deliveryApi.ts`, `src/apis/home/homeApi.ts` | +| 배송 신청 | 배송용 식물 선택, 배송 입력 폼, 신청 완료 화면까지 연결됨 | `src/pages/delivery/UnlockGardenScreen.tsx`, `src/pages/delivery/DeliveryScreen.tsx`, `src/pages/delivery/DeliveryCompleteScreen.tsx`, `src/hooks/delivery/useDeliveryApi.ts`, `src/apis/delivery/deliveryApi.ts` | +| 등록 플로우 | 아바타 시작, 생성형 이미지 업로드, 선택형 상세, 별명 짓기와 홈 복귀까지 연결됨 | `src/pages/registration/RegistrationAvatarScreen.tsx`, `src/pages/registration/RegistrationCreationDetailScreen.tsx`, `src/pages/registration/RegistrationSelectionDetailScreen.tsx`, `src/pages/registration/RegistrationPlantNicknameScreen.tsx`, `src/stores/useRegistrationStore.ts`, `src/hooks/avatars/useAvatarApi.ts`, `src/apis/avatars/avatarApi.ts` | +| 데일리 미션 퀴즈 | 객관식/OX 퀴즈 조회와 답안 제출, 홈 진입점이 연결됨 | `src/pages/dailyMission/DailyMissionQuizMultipleChoiceScreen.tsx`, `src/pages/dailyMission/DailyMissionQuizOxScreen.tsx`, `src/hooks/mission/useMissionApi.ts`, `src/apis/missions/missionApi.ts` | +| 오늘의 질문 미션 | `GET /api/v1/survey` 조회, `POST /api/v1/survey/answer` 제출, 홈 완료 상태 반영이 구현됨. `CHECKING` fallback은 제거되었고 포인트 지급은 서버 책임이다. | `src/pages/dailyMission/DailyMissionCheckingScreen.tsx`, `src/hooks/mission/useMissionApi.ts`, `src/apis/missions/missionApi.ts`, `src/pages/home/HomeScreen.tsx` | +| 설정/로그아웃 | 설정 화면에서 계정 상태 확인, 로그아웃 버튼, 운영 안내를 확인할 수 있음 | `src/pages/option/OptionScreen.tsx`, `src/utils/auth.ts`, `src/stores/useTokenStore.ts`, `src/navigation/RootNavigator.tsx` | + +### 부분구현 + +| 기능명 | 현재 MainAPP 상태 | 보완 필요 사항 | 근거 파일 | +|---|---|---|---| +| 비회원 등록 | 닉네임 입력과 회원가입 API 호출은 있음 | 가입 실패 시에도 등록 플로우로 진입시키는 현재 정책이 운영 기준으로 확정된 것은 아님 | `src/pages/register/RegisterScreen.tsx`, `src/hooks/register/useRegister.ts`, `src/apis/register/registerApi.ts` | +| 홈 메인 | 홈 summary API, `panel` API, 물/햇빛 액션, 감정 체크 모달, 미션 시트, 잠금/빈 슬롯 분기와 missionType 라우팅이 연결됨 | 실기기 스와이프 검증, 안전영역 점검, 문구/간격 같은 UI 마감과 물/햇빛 API 계약 상세 확인이 남아 있음 | `src/pages/home/HomeScreen.tsx`, `src/hooks/home/useHomeApi.ts`, `src/apis/home/homeApi.ts`, `src/components/home/*`, `src/stores/useEmotionSurveyStore.ts` | +| 로그 화면 일부 | 캘린더/일기 목록과 로그 상세까지 연결됨 | 미션 탭 날짜 선택 후 상세 액션은 아직 없음 | `src/pages/log/LogScreen.tsx`, `src/pages/log/LogDetailScreen.tsx`, `src/apis/log/diaryDetailApi.ts` | +| 피드 상세 UX | 조회/댓글/상태 처리와 프로필 이동은 됨 | 공감/신고/댓글 수정·삭제는 아직 없음 | `src/components/feed/FeedDetail.tsx`, `src/pages/feed/FeedDiaryScreen.tsx`, `src/pages/feed/FeedAvatarScreen.tsx` | +| 프로필 | 사용자 조회와 대표 정원/팔로우/물주기까지 연결됨 | 방명록, 다중 정원 상세, 추가 상호작용 API는 아직 필요 | `src/pages/profile/ProfileScreen.tsx`, `src/components/profile/ProfileDetail.tsx`, `src/apis/profile/profileApi.ts` | +| 팔로우 | 팔로잉/팔로워 목록 조회와 탭 전환, 팔로잉 언팔로우가 됨 | 팔로워 목록 쪽 follow-back 액션은 응답 정보 부족으로 아직 없음 | `src/pages/follow/FollowScreen.tsx`, `src/components/follow/UserCard.tsx`, `src/apis/follow/followApi.ts` | +| 배송 | 배송용 식물 목록 조회, 배송 신청, 완료 화면, 홈에서의 진입은 연결됨 | 주소 검색 UI, 배송 조회는 추가 확인 필요 | `src/pages/delivery/UnlockGardenScreen.tsx`, `src/pages/delivery/DeliveryScreen.tsx`, `src/pages/delivery/DeliveryCompleteScreen.tsx`, `src/apis/delivery/deliveryApi.ts` | +| 등록 플로우 | 단계 간 상태 저장과 이동, 생성형 업로드, 선택형/생성형 최종 등록은 구현됨 | 신규 유저 자동 강제 진입 정책은 추가 확인 필요 | `src/pages/registration/RegistrationAvatarScreen.tsx`, `src/pages/registration/RegistrationCreationDetailScreen.tsx`, `src/pages/registration/RegistrationSelectionDetailScreen.tsx`, `src/pages/registration/RegistrationPlantNicknameScreen.tsx`, `src/stores/useRegistrationStore.ts` | +| 데일리 미션 일기 작성 | 텍스트 입력, 공개 설정, 제출 구조는 구현됨 | 이미지 업로드-일기 저장 최종 연결은 아직 미완성 | `src/pages/dailyMission/DailyMissionWriteDiaryScreen.tsx`, `src/apis/missions/missionApi.ts`, `src/hooks/mission/useMissionApi.ts` | +| 설정 | 로그아웃과 계정 상태 확인은 가능함 | 버전 표기 자동화, 약관/문의 같은 운영 링크는 아직 없음 | `src/pages/option/OptionScreen.tsx` | + +## 3. 플레이스홀더/미구현 목록 + +### RootNavigator.tsx 기준 플레이스홀더 스택 화면 + +현재 `RootNavigator` 기준 플레이스홀더 스택 화면은 없다. Step 3~7 범위의 상세 화면 교체가 끝난 상태다. + +| 라우트명 | 현재 상태 | 비고 | +|---|---|---| +| 없음 | 모든 대상 라우트가 실제 화면으로 교체됨 | 남은 항목은 운영 보완/정책 정리 성격 | + +### 파일은 있으나 기능적으로 미구현에 가까운 화면 + +| 파일 | 현재 상태 | 판단 | +|---|---|---| +| 없음 | 주요 화면은 모두 실제 기능을 가짐 | 남은 항목은 운영 보완/정책 정리 위주 | + +## 4. MainFE 대비 누락 기능 매트릭스 + +아래 표는 `C:/MainFE/MAINAPP_MIGRATION_MATRIX.md`를 기준으로, `MainAPP`에 아직 없는 기능 또는 부분구현 기능을 정리한 것이다. + +| 기능명 | 현재 MainAPP 상태 | 참조할 MainFE 파일 | 필요한 MainAPP 대상 파일 또는 신규 파일 | 선행조건 | +|---|---|---|---|---| +| 홈 메인 패널 | `/api/v1/home`와 `panel` 응답 기준 요약 UI, 접힘/펼침 미션 시트, 오늘의 질문 연결까지 구현됨 | `C:/MainFE/src/pages/home/Homepage.tsx`, `C:/MainFE/src/apis/home/homeApi.ts`, `C:/MainFE/src/apis/missions/panelApi.ts` | 기존 `src/pages/home/HomeScreen.tsx`, 기존 `src/apis/home/homeApi.ts`, 기존 `src/hooks/home/useHomeApi.ts`, `src/components/home/*` | 실기기 스와이프와 UI 마감 확인 | +| 식물 상호작용(물/햇빛) | owner 기준 물/햇빛 액션, 버튼 상태, 토스트, 홈 배치까지 구현됨 | `C:/MainFE/src/components/home/FirstPlant.tsx`, `C:/MainFE/src/components/home/SecondPlant.tsx`, `C:/MainFE/src/components/home/ThirdPlant.tsx`, `C:/MainFE/src/components/home/FourthPlant.tsx` | 기존 `src/components/home/HomeGardenScene.tsx`, 기존 `src/hooks/home/useHomeApi.ts`, 기존 `src/pages/home/HomeScreen.tsx` | 액션 API 계약 상세와 실기기 동작 확인 | +| 로그 상세 | diary detail API 기준 실제 화면 구현 완료. 수정 기능과 추가 소셜 액션만 미완성 | `C:/MainFE/src/pages/log/LogDetailPage.tsx` | 기존 `src/pages/log/LogDetailScreen.tsx`, 기존 `src/components/log/MyDiaryDetail.tsx` | 수정 API 여부 확인 | +| 피드 상세 보완 | 상세 조회, 댓글, 상태 처리는 구현됨. 소셜 상호작용은 미완성 | `C:/MainFE/src/pages/feed/FeedDiaryPage.tsx`, `C:/MainFE/src/pages/feed/FeedAvatarPage.tsx`, `C:/MainFE/src/components/feed/*`, `C:/MainFE/src/components/common/Comment.tsx` | 기존 `src/pages/feed/FeedDiaryScreen.tsx`, 기존 `src/pages/feed/FeedAvatarScreen.tsx`, 기존 `src/components/feed/FeedDetail.tsx`, 기존 `src/components/common/Comment.tsx` | 프로필 화면, 댓글/좋아요 API 범위 확인 | +| 프로필 조회/친구 물주기 | 사용자 조회, 대표 정원 렌더링, 친구 물주기, follow/unfollow까지 구현됨. 방명록 등 추가 기능은 미완성 | `C:/MainFE/src/pages/profile/ProfilePage.tsx`, `C:/MainFE/src/apis/profile/profileApi.ts`, `C:/MainFE/src/components/profile/*` | 기존 `src/pages/profile/ProfileScreen.tsx`, 기존 `src/apis/profile/profileApi.ts`, 기존 `src/hooks/profile/*`, 기존 `src/components/profile/*` | 방명록 및 추가 상호작용 API 확인 | +| 팔로우 목록/관리 | 팔로잉/팔로워 목록 조회와 팔로잉 언팔로우는 구현됨. 팔로워 follow-back은 응답 정보 부족으로 미완성 | `C:/MainFE/src/pages/follow/FollowPage.tsx`, `C:/MainFE/src/apis/follow/followApi.ts`, `C:/MainFE/src/components/follow/*` | 기존 `src/pages/follow/FollowScreen.tsx`, 기존 `src/apis/follow/followApi.ts`, 기존 `src/hooks/follow/*`, 기존 `src/components/follow/*` | 팔로워 목록의 관계 상태 또는 follow-back 정책 확인 | +| 설정/로그아웃 UI | 로그아웃 버튼, 계정 상태, 운영 안내까지 구현됨. 운영 링크와 상세 안내는 미완성 | `C:/MainFE/src/pages/option/OptionPage.tsx` | 기존 `src/pages/option/OptionScreen.tsx`, 기존 `src/utils/auth.ts` | 로그아웃 정책 유지 | +| 배송 신청 | 배송 입력 폼과 제출은 구현됨. 주소 검색과 주문 조회는 미완성 | `C:/MainFE/src/pages/delivery/DeliveryPage.tsx`, `C:/MainFE/src/components/delivery/*` | 기존 `src/pages/delivery/DeliveryScreen.tsx`, 기존 `src/components/delivery/*`, 기존 `src/apis/delivery/deliveryApi.ts` | 주소 검색 방식 확정 | +| 배송 완료 | 완료 화면과 홈 복귀 흐름은 구현됨 | `C:/MainFE/src/pages/delivery/CompletePage.tsx` | 기존 `src/pages/delivery/DeliveryCompleteScreen.tsx` | 배송 상태 조회 API 확인 | +| 정원 확장/잠금 해제 | body 없는 unlock 호출과 홈 재조회가 구현됨 | `C:/MainFE/src/pages/delivery/UnlockGardenPlotPage.tsx`, `C:/MainFE/src/components/delivery/*` | 기존 `src/pages/delivery/UnlockGardenScreen.tsx`, 기존 `src/components/delivery/*`, 기존 `src/apis/delivery/deliveryApi.ts` | 홈 화면 연결점 정의 | +| 등록 플로우 시작 | 시작 화면과 mode 선택, 단계 이동은 구현됨 | `C:/MainFE/src/pages/registration/AvatarCreationPage.tsx`, `C:/MainFE/src/components/registration/*`, `C:/MainFE/src/apis/avatars/avatarApi.ts` | 기존 `src/pages/registration/RegistrationAvatarScreen.tsx`, 기존 `src/components/registration/*`, 기존 `src/stores/useRegistrationStore.ts` | 신규 유저 자동 진입 정책 확정 | +| 생성형 등록 상세 | 이미지 선택과 업로드, 상태 저장은 구현됨 | `C:/MainFE/src/pages/registration/CreationDetailPage.tsx` | 기존 `src/pages/registration/RegistrationCreationDetailScreen.tsx`, 기존 `src/stores/useRegistrationStore.ts`, 기존 `src/apis/avatars/avatarApi.ts` | 기기 권한 UX 보완 | +| 선택형 등록 상세 | 아바타 목록 조회와 선택은 구현됨 | `C:/MainFE/src/pages/registration/SelectionDetailPage.tsx`, `C:/MainFE/src/apis/avatars/avatarApi.ts` | 기존 `src/pages/registration/RegistrationSelectionDetailScreen.tsx`, 기존 `src/apis/avatars/avatarApi.ts`, 기존 `src/hooks/avatars/useAvatarApi.ts` | `GET /api/v1/avatars/masters` 계약 유지 | +| 식물 별명 짓기 | 별명 입력과 완료 흐름은 구현됨. 생성형/선택형 모두 `POST /api/v1/avatars`로 연결됨 | `C:/MainFE/src/pages/registration/PlantNicknamePage.tsx`, `C:/MainFE/src/apis/avatars/avatarApi.ts` | 기존 `src/pages/registration/RegistrationPlantNicknameScreen.tsx`, 기존 `src/apis/avatars/avatarApi.ts`, 기존 `src/hooks/avatars/useAvatarApi.ts` | 신규 유저 분기 정책 확인 | +| 데일리 미션 일기 작성 | 텍스트 입력, 공개 설정, 제출 구조는 구현됨. 이미지 업로드-일기 저장은 미완성 | `C:/MainFE/src/pages/dailyMission/WriteDiaryPage.tsx`, `C:/MainFE/src/components/dailyMission/*`, `C:/MainFE/src/apis/missions/*` | 기존 `src/pages/dailyMission/DailyMissionWriteDiaryScreen.tsx`, 기존 `src/components/dailyMission/*`, 기존 `src/apis/missions/missionApi.ts`, 기존 `src/hooks/mission/useMissionApi.ts` | 일기 작성 최종 제출 UX 보완 | +| 데일리 미션 객관식 퀴즈 | 퀴즈 조회, 선택지 렌더링, 답안 제출, 결과 표시가 구현됨 | `C:/MainFE/src/pages/dailyMission/MultipleChoiceQuestionQuizPage.tsx`, `C:/MainFE/src/components/dailyMission/*`, `C:/MainFE/src/apis/missions/*` | 기존 `src/pages/dailyMission/DailyMissionQuizMultipleChoiceScreen.tsx`, 기존 `src/components/dailyMission/*`, 기존 `src/apis/missions/missionApi.ts` | `GET/POST /api/v1/realQuiz` 계약 유지 | +| 데일리 미션 OX 퀴즈 | 퀴즈 조회, O/X 선택, 답안 제출, 결과 표시가 구현됨 | `C:/MainFE/src/pages/dailyMission/OxQuizPage.tsx`, `C:/MainFE/src/components/dailyMission/*`, `C:/MainFE/src/apis/missions/*` | 기존 `src/pages/dailyMission/DailyMissionQuizOxScreen.tsx`, 기존 `src/components/dailyMission/*`, 기존 `src/apis/missions/missionApi.ts` | OX와 객관식 공통 quiz 계약 유지 | +| 데일리 미션 오늘의 질문 | survey 조회, YES/NEUTRAL/NO 답변 제출, 홈 완료 상태 반영이 구현됨 | `C:/MainFE/src/pages/dailyMission/*`, `C:/MainFE/src/apis/missions/*` | 기존 `src/pages/dailyMission/DailyMissionCheckingScreen.tsx`, 기존 `src/apis/missions/missionApi.ts`, 기존 `src/hooks/mission/useMissionApi.ts` | 응답 문구/보상 노출 정책 확정 | + +## 5. 우선순위별 작업 백로그 + +## P0. 인증 기반 후속 점검 항목 + +### P0-1. 로그아웃 UI 연결 +- 기능명: 설정 화면 로그아웃 연결 +- 현재 MainAPP 상태: 완료. 설정 화면에서 로그아웃 버튼과 중복 클릭 방지가 연결됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/option/OptionPage.tsx` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/option/OptionScreen.tsx` +- 선행조건: 없음 + +### P0-2. 인증 실패 UX 정리 +- 기능명: 401/403 이후 사용자 경험 정리 +- 현재 MainAPP 상태: 토큰 제거 및 인증 해제는 되지만 안내 UI와 공통 처리 메시지는 없음 +- 참조할 MainFE 파일: `C:/MainFE/AUTH_INTEGRATION_CHECKLIST.md` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/apis/instance.ts`, 필요 시 신규 `src/components/common/*` +- 선행조건: 없음 + +### P0-3. 신규 유저 분기 확정 +- 기능명: OAuth/비회원 등록 이후 신규 유저 후속 플로우 명시 +- 현재 MainAPP 상태: OAuth `newUser`는 `RegistrationAvatar`로 reset되고, 비회원 등록도 등록 플로우로 진입함. 다만 중도 이탈 후 재진입 정책은 확정되지 않음 +- 참조할 MainFE 파일: `C:/MainFE/AUTH_INTEGRATION_CHECKLIST.md` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/hooks/auth/useSupabaseOAuth.ts`, 기존 `src/pages/register/RegisterScreen.tsx`, 향후 등록 플로우 화면들 +- 선행조건: 등록 플로우 목표 화면 구조 합의 + +## P1. 홈/로그/피드 코어 기능 + +### P1-1. 홈 메인 구현 +- 기능명: 홈 메인 화면 및 패널 +- 현재 MainAPP 상태: `HomeScreen`이 `/api/v1/home` 응답 기준 요약 UI를 렌더링하고, `missionType` 기반으로 실제 미션 화면을 연결함 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/home/Homepage.tsx`, `C:/MainFE/src/apis/home/homeApi.ts`, `C:/MainFE/src/apis/missions/panelApi.ts` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/home/HomeScreen.tsx`, 기존 `src/apis/home/homeApi.ts`, 기존 `src/hooks/home/useHomeApi.ts`, 필요 시 신규 `src/components/home/*` +- 선행조건: MainBE 홈/패널 API 확인 + +### P1-2. 로그 상세 구현 +- 기능명: 로그 상세 화면 +- 현재 MainAPP 상태: 실제 상세 화면과 댓글 입력이 연결됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/log/LogDetailPage.tsx` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/log/LogDetailScreen.tsx`, 기존 `src/apis/log/diaryDetailApi.ts`, 기존 `src/hooks/log/useDiaryDetailApi.ts` +- 선행조건: 없음 + +### P1-3. 피드 상세 보강 +- 기능명: 피드 상세 UX 및 상호작용 보강 +- 현재 MainAPP 상태: 조회와 댓글 작성은 가능하나 프로필/좋아요/신고 등 소셜 기능이 미완성 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/feed/FeedDiaryPage.tsx`, `C:/MainFE/src/pages/feed/FeedAvatarPage.tsx`, `C:/MainFE/src/components/feed/*`, `C:/MainFE/src/components/common/Comment.tsx` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/feed/FeedDiaryScreen.tsx`, 기존 `src/pages/feed/FeedAvatarScreen.tsx`, 기존 `src/components/feed/FeedDetail.tsx`, 기존 `src/components/common/Comment.tsx` +- 선행조건: 프로필 화면 스펙과 좋아요/신고 API 확인 + +### P1-4. 로그 미션 탭 후속 +- 기능명: 로그 내 미션 탭 상세 연결 +- 현재 MainAPP 상태: 캘린더는 렌더링되나 날짜 선택 후 액션이 없음 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/log/LogPage.tsx`, `C:/MainFE/src/components/log/*` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/components/log/LogCalendar.tsx`, 기존 `src/pages/log/LogScreen.tsx` +- 선행조건: 날짜 선택 시 보여줄 정보 구조 확정 + +## P2. 프로필/팔로우/설정 + +### P2-1. 프로필 화면 구현 +- 기능명: 사용자 프로필 조회 및 친구 물주기 +- 현재 MainAPP 상태: 사용자 조회, 대표 정원, 물주기, 팔로우/언팔로우까지 구현됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/profile/ProfilePage.tsx`, `C:/MainFE/src/apis/profile/profileApi.ts`, `C:/MainFE/src/components/profile/*` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/profile/ProfileScreen.tsx`, 기존 `src/apis/profile/profileApi.ts`, 기존 `src/hooks/profile/*`, 기존 `src/components/profile/*` +- 선행조건: 프로필 API 확인 + +### P2-2. 팔로우 화면 구현 +- 기능명: 팔로우 목록 및 관리 +- 현재 MainAPP 상태: 목록 조회, 탭 전환, 팔로잉 언팔로우, 프로필 이동까지 구현됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/follow/FollowPage.tsx`, `C:/MainFE/src/apis/follow/followApi.ts`, `C:/MainFE/src/components/follow/*` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/follow/FollowScreen.tsx`, 기존 `src/apis/follow/followApi.ts`, 기존 `src/hooks/follow/*` +- 선행조건: 팔로우 API 확인 + +### P2-3. 설정 화면 마감 +- 기능명: 설정/옵션 +- 현재 MainAPP 상태: 로그아웃과 계정 상태 확인은 가능, 운영 링크는 미완성 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/option/OptionPage.tsx` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/option/OptionScreen.tsx` +- 선행조건: 로그아웃 버튼 정책 확정 + +## P3. 배송/정원확장/등록플로우/데일리미션 + +### P3-1. 배송 신청 +- 기능명: 배송 신청 폼 +- 현재 MainAPP 상태: 기본 배송 입력과 제출이 구현됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/delivery/DeliveryPage.tsx`, `C:/MainFE/src/components/delivery/*` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/delivery/DeliveryScreen.tsx`, 기존 `src/components/delivery/*`, 기존 `src/apis/delivery/deliveryApi.ts` +- 선행조건: 배송 API 확인 + +### P3-2. 배송 완료 +- 기능명: 배송 완료 화면 +- 현재 MainAPP 상태: 완료 메시지와 홈 복귀 버튼이 구현됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/delivery/CompletePage.tsx` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/delivery/DeliveryCompleteScreen.tsx` +- 선행조건: 배송 신청 플로우 구현 + +### P3-3. 정원 확장 +- 기능명: 정원 잠금 해제/확장 +- 현재 MainAPP 상태: `POST /api/v1/gardens/unlock` body 없는 호출과 홈 재조회가 구현됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/delivery/UnlockGardenPlotPage.tsx`, `C:/MainFE/src/components/delivery/*` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/delivery/UnlockGardenScreen.tsx`, 기존 `src/components/delivery/*`, 기존 `src/apis/delivery/deliveryApi.ts` +- 선행조건: 홈 화면 연결점 정의 + +### P3-4. 등록 플로우 +- 기능명: 아바타 선택, 생성형/선택형 상세, 식물 별명 +- 현재 MainAPP 상태: 단계별 화면과 상태 저장, 생성형 업로드, 최종 등록까지 구현됨 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/registration/AvatarCreationPage.tsx`, `C:/MainFE/src/pages/registration/CreationDetailPage.tsx`, `C:/MainFE/src/pages/registration/SelectionDetailPage.tsx`, `C:/MainFE/src/pages/registration/PlantNicknamePage.tsx`, `C:/MainFE/src/components/registration/*`, `C:/MainFE/src/apis/avatars/avatarApi.ts` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/registration/*`, 기존 `src/components/registration/*`, 기존 `src/apis/avatars/avatarApi.ts`, 기존 `src/hooks/avatars/useAvatarApi.ts`, 기존 `src/stores/useRegistrationStore.ts` +- 선행조건: 신규 유저 분기 규칙과 등록 플로우 상태 저장 전략 확정 + +### P3-5. 데일리 미션 +- 기능명: 일기 작성, 객관식 퀴즈, OX 퀴즈, 오늘의 질문 +- 현재 MainAPP 상태: 퀴즈와 오늘의 질문은 구현, 일기 작성은 최종 이미지 업로드-저장 연결만 미완성 +- 참조할 MainFE 파일: `C:/MainFE/src/pages/dailyMission/WriteDiaryPage.tsx`, `C:/MainFE/src/pages/dailyMission/MultipleChoiceQuestionQuizPage.tsx`, `C:/MainFE/src/pages/dailyMission/OxQuizPage.tsx`, `C:/MainFE/src/components/dailyMission/*`, `C:/MainFE/src/apis/missions/*` +- 필요한 MainAPP 대상 파일 또는 신규 파일: 기존 `src/pages/dailyMission/*`, 기존 `src/components/dailyMission/*`, 기존 `src/apis/missions/missionApi.ts`, 기존 `src/hooks/mission/useMissionApi.ts` +- 선행조건: 일기 작성 업로드-저장 UX 마무리 + +## 6. 이후 Codex 작업 순서 + +### Step 3. 홈/로그/피드 공백 메우기 +- `src/pages/home/HomeScreen.tsx` 기준 홈 상호작용 기본 연결은 끝났고, 남은 작업은 실기기 스와이프 확인, 안전영역/작은 화면 점검, 문구/간격 마감, 물/햇빛 API 계약 상세 확인이다. +- `src/pages/log/LogDetailScreen.tsx` 이후, 수정 기능과 미션 탭 상세 연결을 보완한다. +- 피드 상세의 좋아요/신고/프로필 실화면 연결 등 미완성 소셜 상호작용을 정리한다. +- 필요한 홈/피드 보조 API와 훅을 추가한다. + +### Step 4. 프로필/팔로우 +- `Profile`과 `Follow` 플레이스홀더 교체는 완료되었다. +- 피드 상세 작성자 탭과 팔로우 화면 진입 버튼까지 실제 네비게이션 연결이 완료되었다. +- 이후 남은 작업은 방명록, 팔로워 follow-back, 추가 프로필 상호작용 같은 세부 기능 보강이다. + +### Step 5. 배송/정원 확장 +- `Delivery`, `DeliveryComplete`, `UnlockGarden` 플레이스홀더 교체는 완료되었다. +- 홈 화면에서 `UnlockGarden`으로 이어지는 연결점과 배송 완료 후 홈 복귀 흐름이 정리되었다. +- 이후 남은 작업은 주소 검색과 배송 상태 조회다. + +### Step 6. 등록 플로우 +- `RegistrationAvatar`, `RegistrationCreationDetail`, `RegistrationSelectionDetail`, `RegistrationPlantNickname` 플레이스홀더 교체는 완료되었다. +- 비회원 등록 진입과 OAuth 신규 유저 분기 연결 포인트가 정리되었다. +- 이후 남은 작업은 신규 유저 강제 진입 정책 확정과 생성형 업로드 UX 보완이다. + +### Step 7. 데일리 미션 +- `DailyMissionWriteDiary`, `DailyMissionQuizMultipleChoice`, `DailyMissionQuizOx`, `DailyMissionChecking` 화면이 실제로 연결되었다. +- 홈에서 미션 화면으로 진입하는 연결점과 퀴즈/오늘의 질문 제출 흐름이 정리되었다. +- 이후 남은 작업은 일기 작성 최종 업로드-저장 연결과 운영 보완 성격의 정책 정리다. + +## 7. 운영 마감 상태 + +### 완료된 범위 +- 인증, 온보딩, 로그인, 재실행 세션 복원, 로그아웃 +- 홈 주요 진입점, `panel` 기반 미션 시트, 감정 체크, 물/햇빛 액션, 피드/로그 상세, 프로필/팔로우 +- 정원 해금, 배송 신청/완료 +- 등록 플로우 기본 구조와 생성형 업로드 +- 데일리 미션 진입, 퀴즈 응답, 오늘의 질문 응답 + +### 부분구현 범위 +- 홈 UI 마감과 실기기 검증 +- 피드 소셜 액션(좋아요/신고/댓글 수정 삭제) +- 프로필 방명록/다중 정원 +- 배송 주소 검색과 배송 조회 +- 비회원 등록/신규 유저 정책 +- 데일리 미션 일기 작성 최종 제출 +- 설정의 운영 링크/버전 자동 표기 + +### 정책 미확정 범위 +- 신규 유저가 등록 플로우를 중도 이탈했을 때 재진입을 강제할지 여부 +- 비회원 등록 실패 시에도 등록 플로우로 진입시키는 현재 동작을 유지할지 여부 +- 오늘의 질문 완료 후 보상/안내 문구를 앱에서 어느 수준까지 노출할지 여부 + +### 백엔드 계약 확인 필요 범위 +- 물/햇빛 액션 API 계약 상세 +- 팔로워 목록의 follow-back 판단용 관계 상태 +- 배송 상태 조회와 주소 검색 대체 정책 +- 데일리 미션 일기 작성의 최종 저장 UX 정책 + +### 운영 보완 필요 범위 +- 설정 화면의 약관/문의/버전 안내 연결 +- 인증 실패 공통 안내 UX +- 문구 일관화와 API 실패 메시지 다듬기 + +## 8. 남은 리스크 + +- 데일리 미션 일기 작성은 이미지 업로드-일기 저장 최종 연결이 아직 완결되지 않았다. +- 비회원 등록 실패 시에도 등록 플로우로 진입시키는 현재 동작은 운영 정책 확정이 필요하다. +- 홈 화면은 기본 상호작용이 구현됐지만, 물/햇빛 API 계약 상세와 실기기 레이아웃 검증 전까지는 운영 리스크가 남아 있다. +- 배송 주소 검색과 배송 상태 조회는 아직 앱에서 직접 지원하지 않는다. +- MainFE 종료 이후 기준 문서는 이 파일과 현재 `MainAPP` 코드이며, 남은 보완은 정책/운영 확정 후 `MainAPP`에서 계속 갱신한다. + +## 부록. 현재 파일 기준 빠른 판단 메모 + +### 실제 구현이 있는 현재 화면 +- `src/pages/onboarding/OnboardingScreen.tsx` +- `src/pages/register/RegisterScreen.tsx` +- `src/pages/home/HomeScreen.tsx` +- `src/pages/log/LogScreen.tsx` +- `src/pages/log/LogDetailScreen.tsx` +- `src/pages/feed/FeedScreen.tsx` +- `src/pages/feed/FeedDiaryScreen.tsx` +- `src/pages/feed/FeedAvatarScreen.tsx` +- `src/pages/profile/ProfileScreen.tsx` +- `src/pages/follow/FollowScreen.tsx` +- `src/pages/delivery/UnlockGardenScreen.tsx` +- `src/pages/delivery/DeliveryScreen.tsx` +- `src/pages/delivery/DeliveryCompleteScreen.tsx` +- `src/pages/registration/RegistrationAvatarScreen.tsx` +- `src/pages/registration/RegistrationCreationDetailScreen.tsx` +- `src/pages/registration/RegistrationSelectionDetailScreen.tsx` +- `src/pages/registration/RegistrationPlantNicknameScreen.tsx` +- `src/pages/dailyMission/DailyMissionWriteDiaryScreen.tsx` +- `src/pages/dailyMission/DailyMissionQuizMultipleChoiceScreen.tsx` +- `src/pages/dailyMission/DailyMissionQuizOxScreen.tsx` +- `src/pages/dailyMission/DailyMissionCheckingScreen.tsx` +- `src/pages/option/OptionScreen.tsx` + +### 파일은 있으나 운영 보완이 남은 화면 +- `src/pages/option/OptionScreen.tsx` +- `src/pages/dailyMission/DailyMissionWriteDiaryScreen.tsx` + +### 실제 구현이 있는 공용 컴포넌트 +- `src/components/common/Splash.tsx` +- `src/components/common/Comment.tsx` +- `src/components/common/ScreenHeader.tsx` +- `src/components/common/StatusView.tsx` +- `src/components/feed/FeedList.tsx` +- `src/components/feed/FeedDetail.tsx` +- `src/components/log/LogCalendar.tsx` +- `src/components/log/MyDiary.tsx` +- `src/components/profile/ProfileDetail.tsx` +- `src/components/follow/UserCard.tsx` +- `src/components/delivery/PlantOptionCard.tsx` +- `src/components/delivery/GardenSlotCard.tsx` +- `src/components/delivery/DeliveryTextField.tsx` +- `src/components/delivery/DeliveryRequestSelector.tsx` +- `src/components/registration/AvatarPreviewCard.tsx` +- `src/components/registration/RegistrationFooter.tsx` +- `src/components/registration/RegistrationModeCard.tsx` +- `src/components/registration/SelectionAvatarCard.tsx` +- `src/components/registration/RegistrationTextField.tsx` +- `src/components/dailyMission/ImageAttachmentCard.tsx` +- `src/components/dailyMission/QuizOptionCard.tsx` +- `src/components/dailyMission/QuizResultCard.tsx` + +### 실제 연결된 API +- `src/apis/feed/feedApi.ts` +- `src/apis/feed/avatarPostDetailApi.ts` +- `src/apis/comments/commentApi.ts` +- `src/apis/log/calendarApi.ts` +- `src/apis/log/diariesApi.ts` +- `src/apis/log/diaryDetailApi.ts` +- `src/apis/profile/profileApi.ts` +- `src/apis/follow/followApi.ts` +- `src/apis/delivery/deliveryApi.ts` +- `src/apis/avatars/avatarApi.ts` +- `src/apis/missions/missionApi.ts` +- `src/apis/register/registerApi.ts` +- `src/apis/instance.ts` + diff --git a/MAINBE_TRACKING_REPORT_TODO.md b/MAINBE_TRACKING_REPORT_TODO.md new file mode 100644 index 0000000..5ee6295 --- /dev/null +++ b/MAINBE_TRACKING_REPORT_TODO.md @@ -0,0 +1,238 @@ +# MainBE 2주 리포트 구현 TODO + +## 목적 + +- 홈에서 자동 노출되는 `2주 리포트 확인 모달`의 판단 기준을 MainBE에서 단일화한다. +- MainAPP은 `eligible`만 신뢰하고, 주기 계산과 확인 여부 저장은 모두 MainBE가 담당한다. + +## 완료 기준 + +- `GET /api/v1/tracking/report/status` 응답으로 홈 자동 노출 여부를 정확히 판단할 수 있어야 한다. +- `POST /api/v1/tracking/report/confirm` 호출 후 같은 `cycleKey`는 다시 노출되지 않아야 한다. +- 최근 14일 perfect day 계산 기준이 앱과 분리되고, 서버 기준으로만 동작해야 한다. + +--- + +## 1단계. 응답/요청 DTO 정의 + +### 해야 할 일 + +- 상태 조회 응답 DTO 추가 +- 확인 완료 요청 DTO 추가 + +### 권장 DTO + +#### 상태 조회 응답 + +```json +{ + "eligible": true, + "alreadyViewed": false, + "perfectDayCount": 14, + "cycleKey": "2026-03-13", + "windowStart": "2026-02-28", + "windowEnd": "2026-03-13", + "message": "2주 동안 꾸준히 돌봐주셨어요." +} +``` + +#### 확인 완료 요청 + +```json +{ + "cycleKey": "2026-03-13" +} +``` + +### 체크 포인트 + +- `eligible`는 앱 표시용 최종 boolean 이어야 한다. +- `cycleKey`는 같은 주기를 식별할 수 있는 고정 값이어야 한다. +- `message`는 기존 리포트 문구 재사용 가능하도록 열어둔다. + +--- + +## 2단계. 확인 이력 저장 모델 추가 + +### 해야 할 일 + +- `TrackingReportView` 같은 확인 이력 엔티티/테이블 추가 + +### 권장 필드 + +- `id` +- `user` +- `cycleKey` +- `viewedAt` + +### 제약 조건 + +- `user + cycleKey` unique + +### 체크 포인트 + +- 같은 사용자가 같은 주기를 중복 confirm 해도 데이터가 꼬이지 않아야 한다. +- 이후 운영 중 확인 이력 조회가 가능하도록 최소 필드는 남긴다. + +--- + +## 3단계. perfect day 계산 로직 분리 + +### 해야 할 일 + +- 최근 14일 기준 perfect day 계산 메서드 추가 +- 현재 주기의 `windowStart`, `windowEnd`, `cycleKey` 계산 로직 추가 + +### perfect day 기준 + +- 해당 날짜에 `물 주기`와 `햇빛 주기`를 모두 완료한 날 + +### 체크 포인트 + +- 앱에서 계산하지 않고 서버에서만 계산해야 한다. +- 최근 14일 윈도우 기준이 고정되어야 한다. +- `cycleKey`는 이 14일 윈도우를 안정적으로 식별할 수 있어야 한다. + +--- + +## 4단계. 상태 조회 API 추가 + +### 엔드포인트 + +- `GET /api/v1/tracking/report/status` + +### 해야 할 일 + +- 최근 14일 perfect day 수 계산 +- 현재 주기 식별 +- 해당 주기 confirm 여부 조회 +- `eligible` 계산 + +### 권장 계산식 + +```text +eligible = perfectDayCount >= 14 && alreadyViewed == false +``` + +### 체크 포인트 + +- MainAPP은 이 값만 보고 모달을 띄우므로, 여기서 최종 판단이 끝나야 한다. +- 홈 재진입 시에도 결과가 일관되어야 한다. + +--- + +## 5단계. 확인 완료 API 추가 + +### 엔드포인트 + +- `POST /api/v1/tracking/report/confirm` + +### 해야 할 일 + +- 요청으로 받은 `cycleKey` 검증 +- 현재 사용자 기준 확인 이력 저장 +- 이미 존재하면 중복 저장 없이 성공 처리하거나 안전하게 예외 처리 + +### 체크 포인트 + +- 앱에서 닫기/CTA 둘 다 이 API를 호출하므로 idempotent 하게 만드는 편이 안전하다. +- 다른 주기의 `cycleKey`를 잘못 넣었을 때 처리 정책을 정해야 한다. + +--- + +## 6단계. 기존 activity 기록 로직과 연결 확인 + +### 해야 할 일 + +- 현재 물/햇빛 기록 로직은 유지 +- 상태 조회 시점에만 perfect day 조건이 반영되도록 연결 확인 + +### 체크 포인트 + +- 실시간 push 없이도 홈에서 상태 조회만 하면 즉시 반영되어야 한다. +- 14번째 perfect day가 완성된 당일, 다음 상태 조회에서 `eligible=true`가 나와야 한다. + +--- + +## 7단계. 서비스 계층 구조 정리 + +### 권장 분리 + +- `TrackingReportService` +- `TrackingReportQueryService` 또는 기존 서비스 내부 메서드 분리 + +### 포함되면 좋은 메서드 + +- `getTrackingPromptStatus(userId)` +- `confirmTrackingPrompt(userId, cycleKey)` +- `calculatePerfectDayCount(userId, windowStart, windowEnd)` +- `resolveCycleKey(windowStart, windowEnd)` + +### 체크 포인트 + +- 컨트롤러에서 계산 로직이 직접 섞이지 않게 한다. +- 테스트 가능한 단위로 메서드를 나눈다. + +--- + +## 8단계. 예외 처리 정책 정리 + +### 확인할 항목 + +- 잘못된 `cycleKey` 요청 +- 인증되지 않은 사용자 요청 +- 중복 confirm 요청 +- activity 데이터가 일부 비정상인 사용자 + +### 권장 방향 + +- 중복 confirm 은 성공 처리 +- 잘못된 요청만 명확히 4xx 처리 + +--- + +## 9단계. 테스트 코드 작성 + +### 최소 테스트 케이스 + +- 14일 미만이면 `eligible=false` +- 14일 달성 직후 `eligible=true` +- 같은 `cycleKey` confirm 후 `eligible=false` +- 다음 주기 달성 시 새 `cycleKey`로 다시 `eligible=true` +- 중복 confirm 요청 시 안전하게 처리 + +### 추가 테스트 케이스 + +- 최근 14일 경계 날짜 포함 여부 +- 당일 물만 완료 / 햇빛만 완료인 경우 perfect day 제외 + +--- + +## 10단계. MainAPP 연동 확인 포인트 + +### MainBE 완료 후 확인할 것 + +- 홈 진입 시 `status` 응답 정상 +- 물/햇빛 완료 후 홈 복귀 시 `eligible` 갱신 정상 +- 모달 확인 후 같은 주기 재노출 없음 +- 다음 주기에서 다시 노출됨 + +--- + +## 구현 순서 요약 + +1. DTO 추가 +2. 확인 이력 엔티티/테이블 추가 +3. perfect day 및 cycleKey 계산 로직 구현 +4. `GET /tracking/report/status` 추가 +5. `POST /tracking/report/confirm` 추가 +6. 서비스 분리 및 예외 처리 정리 +7. 테스트 코드 작성 +8. MainAPP 연동 QA + +--- + +## 메모 + +- MainAPP 쪽은 이미 `status` 조회와 `confirm` 호출 기준으로 연결되어 있다. +- MainBE는 반드시 앱 계산이 아닌 서버 계산 기준으로 `eligible`을 내려줘야 한다. diff --git a/PERFORMANCE_DIAGNOSIS_PLAN.md b/PERFORMANCE_DIAGNOSIS_PLAN.md new file mode 100644 index 0000000..94b53fd --- /dev/null +++ b/PERFORMANCE_DIAGNOSIS_PLAN.md @@ -0,0 +1,199 @@ +# 성능 점검 계획 + +## 결론 + +현재 체감되는 "전체적으로 조금 느림"은 `CDN 하나로 해결될 가능성`보다 +`API 호출 수`, `불필요한 refetch`, `이미지 로딩`, `Railway ↔ Supabase 응답 시간` 문제일 가능성이 더 높다. + +즉 우선순위는 다음과 같다. + +1. 어떤 화면에서 느린지 계측 +2. 어떤 API가 느린지 확인 +3. 프론트의 중복 refetch / invalidate 정리 +4. 이미지 최적화 +5. 마지막으로 CDN 검토 + +## CDN이 직접 해결하는 영역 + +CDN이 효과적인 경우: + +- 큰 이미지 +- 썸네일 +- 배경 이미지 +- 정적 에셋 + +CDN만으로 잘 안 풀리는 경우: + +- 홈 진입 시 API 여러 개 동시 호출 +- Railway 서버 응답 지연 +- Supabase 쿼리 지연 +- 화면 전환 때 과한 refetch +- 모달/상세 진입마다 같은 데이터를 다시 읽는 구조 + +## 현재 구조에서 우선 의심할 부분 + +### 1. 홈 화면 + +홈은 진입/복귀 시 여러 쿼리가 겹칠 가능성이 있다. + +- `home-summary` +- `home-panel` +- `tracking-report-status` +- 알림/방명록 모달 진입 시 추가 조회 + +특히 `invalidateQueries`가 많은 구조라 +작은 액션 뒤에도 홈 관련 API가 연쇄적으로 다시 호출될 수 있다. + +### 2. 피드 상세 / 무한 스크롤 + +피드 상세는 선택 포스트 + 랜덤 세션 + 댓글/공감 갱신이 섞여 있어서 +한 번의 진입에서 네트워크 왕복이 누적될 수 있다. + +### 3. 타인 프로필 + +타인 프로필은 다음 요소가 겹친다. + +- 프로필 조회 +- 친구 물주기 +- 방명록 화면 진입 +- 방명록 작성 후 invalidate + +즉 프로필 화면이 단순 조회처럼 보여도 +실제론 상호작용 이후 재조회 비용이 있다. + +### 4. 이미지 로딩 + +배경 / 식물 / 프로필 / 피드 이미지가 최적화되지 않은 원본이면 +네트워크보다 렌더 체감이 더 느릴 수 있다. + +특히 다음을 의심한다. + +- 원본 해상도 이미지 직접 사용 +- 썸네일 URL 부재 +- 캐시 정책 불명확 + +## 먼저 확인할 화면 + +다음 4개 화면을 우선 계측한다. + +1. 홈 첫 진입 +2. 피드 상세 진입 +3. 타인 프로필 진입 +4. 비둘기 모달 / 방명록 모달 열기 + +각 화면에서 확인할 값: + +- 첫 화면 표시까지 걸린 시간 +- API 응답 시간 +- 이미지가 다 보일 때까지 걸린 시간 +- 같은 API가 중복 호출되는지 여부 + +## 프론트 점검 항목 + +### 1. 중복 refetch + +다음을 우선 확인한다. + +- `useFocusEffect`에서 매번 refetch하는 쿼리 +- 액션 성공 후 `invalidateQueries`를 여러 번 호출하는 구조 +- 이미 최신 캐시가 있는데도 상세 진입 시 다시 읽는 구조 + +### 2. 낙관적 업데이트 가능한 부분 + +다음은 서버 재조회 대신 로컬 반영이 가능한 후보다. + +- 알림 읽음 처리 +- 좋아요 토글 +- 댓글 수 증가 +- 친구 물주기 후 버튼 상태 + +### 3. 이미지 표시 최적화 + +확인 포인트: + +- 큰 원본 이미지를 그대로 쓰는지 +- 썸네일과 상세 이미지를 분리할 수 있는지 +- 리스트에서는 작은 이미지, 상세에서 큰 이미지를 쓰는지 + +## 백엔드 점검 항목 + +### 1. 느린 API 찾기 + +우선 로그로 응답 시간이 느린 API를 찾는다. + +- `/api/v1/home` +- `/api/v1/home/panel` +- `/api/v1/notifications` +- `/api/v1/users/{id}` +- 피드 상세 관련 API + +### 2. Supabase 쿼리 비용 + +확인 포인트: + +- N+1 조회 +- 정렬 + 조인 + 카운트가 한 번에 많은 API +- 인덱스 없는 조건 검색 + +### 3. Railway 환경 + +확인 포인트: + +- cold start 체감 여부 +- 연결 풀 초기화 시간 +- 외부 스토리지/DB 왕복 시간 + +## 추천 계측 방법 + +### 프론트 + +- 화면 진입 시점 로그 +- API 요청 시작/종료 시간 로그 +- 이미지 onLoad 완료 시간 로그 + +### 백엔드 + +- 주요 API의 시작/종료 시간 로그 +- 느린 쿼리 로그 +- 필요한 경우 endpoint 별 응답 시간 측정 + +## 개선 우선순위 + +### 1차 + +- 과한 refetch / invalidate 줄이기 +- 알림/좋아요/물주기 같은 짧은 액션은 로컬 상태 우선 반영 +- 홈/피드/프로필 진입 시 중복 호출 제거 + +### 2차 + +- 이미지 최적화 +- 썸네일 도입 +- 캐시 전략 정리 + +### 3차 + +- 백엔드 느린 쿼리 최적화 +- 인덱스 / fetch 전략 조정 + +### 4차 + +- CDN 적용 +- 이미지/정적 에셋 위주로 우선 적용 + +## 실무 판단 + +현재는 `CDN부터 붙이는 단계`가 아니라 +`어디가 느린지 계측해서 병목을 찾는 단계`다. + +가장 먼저 할 일은 다음 둘이다. + +1. 홈 / 피드 / 프로필 / 알림 모달의 실제 체감 지연 구간을 로그로 잡기 +2. 각 구간에서 API와 이미지 중 무엇이 병목인지 분리하기 + +이후 결과에 따라 + +- API 병목이면 쿼리/호출 수 최적화 +- 이미지 병목이면 CDN + 썸네일 + +순서로 들어가는 것이 맞다. diff --git a/PROFILE_GUESTBOOK_REWORK_PLAN.md b/PROFILE_GUESTBOOK_REWORK_PLAN.md new file mode 100644 index 0000000..8c695fe --- /dev/null +++ b/PROFILE_GUESTBOOK_REWORK_PLAN.md @@ -0,0 +1,246 @@ +# 타인 프로필 / 방명록 재구성 계획 + +## 목표 + +타인 프로필 화면을 현재의 "대표 정원 1개 + 카드형 상세" 구조에서 벗어나, +홈 화면과 유사한 정원 중심 화면으로 재구성한다. + +핵심 UX 목표는 아래와 같다. + +1. 타인 프로필도 정원 중심의 전체 화면 레이아웃을 사용한다. +2. 정원은 홈처럼 좌우 스와이프가 가능하다. +3. 단, 타인 프로필은 해금된 정원 개수만큼만 스와이프 가능하다. +4. 상단에는 뒤로가기, 정원/식물 이름, 친구 추가/언팔로우 버튼이 함께 보여야 한다. +5. 우측 액션 레일에는 홈처럼 물주기 아이콘이 떠야 한다. +6. 남은 친구 물주기 횟수도 함께 보여야 한다. +7. 하단에는 방명록 진입 버튼이 있어야 한다. +8. 방명록 버튼을 누르면 별도 전체 화면의 방명록 페이지로 이동해야 한다. +9. 방명록 페이지에서는 기존 방명록 목록이 위에서부터 스크롤되고, + 하단 고정 입력창으로 새 방명록을 작성할 수 있어야 한다. +10. 받은 유저는 홈의 비둘기 알림에서 방명록 목록을 확인할 수 있어야 한다. + +## 현재 상태와 의도 불일치 + +현재 프로필 구현은 아래와 같은 한계가 있다. + +1. `ProfileScreen`은 `data.userGardens[0]`만 사용한다. + 즉, 다중 정원 / 스와이프 UX가 전혀 없다. +2. `ProfileDetail`은 카드형 상세 구조라 홈 화면의 몰입형 정원 화면과 다르다. +3. 현재 프로필 타입의 `GardenInfo`에는 `gardenImageUrl`, `gardenSlotNumber`, `isLocked` 같은 정보가 없다. + 따라서 홈 화면의 정원 배경/슬롯 로직을 그대로 재사용할 수 없다. +4. 방명록은 홈의 `HomeAlertsModal`에서 읽기만 가능하고, + 별도 전체 화면 작성 플로우가 없다. +5. 프로필에서 방명록 버튼을 눌렀을 때 이동할 라우트/페이지가 아직 없다. + +## 데이터 전제 + +현재 백엔드 기준으로 프로필 API는 아래 데이터만 확실하다. + +- `GetUserProfileResponse` + - `id` + - `userNickname` + - `profileImageUrl` + - `followStatus` + - `leftWaterCountForOthers` + - `userGardens` +- `GardenInfo` + - `gardenId` + - `avatarInfo` + - `isWateringAbleByMe` + +현재 스펙 기준으로는 아래 사항을 가정해야 한다. + +1. `userGardens`는 "해금된 정원만" 내려온다. +2. 타인 프로필은 `userGardens.length`만큼만 페이지를 만들면 된다. +3. 정원 배경 이미지가 없다면 홈 배경의 fallback 이미지를 사용한다. +4. 물주기 카운트 표기는 당장은 `leftWaterCountForOthers`만 노출한다. + `1/5` 같은 최대치 표기는 API가 보장하지 않으므로 하드코딩을 피한다. + +추후 백엔드가 아래 필드를 내려주면 더 정확하게 확장 가능하다. + +- `gardenSlotNumber` +- `gardenImageUrl` +- `isLocked` +- `maxWaterCountForOthers` + +## 목표 화면 구조 + +### 1. 타인 프로필 화면 + +`ProfileScreen`을 홈과 유사한 구조로 재편한다. + +- 최상위는 전체 화면 배경형 레이아웃 +- 내부에 `PagerView` 사용 +- `userGardens.length`만큼만 페이지 생성 +- 각 페이지는 `ProfileGardenScene` 컴포넌트로 렌더링 + +페이지마다 보여야 하는 요소: + +- 상단 + - 뒤로가기 + - 현재 정원의 식물 이름 또는 기본 이름 + - 친구 추가 / 맞팔로우 / 언팔로우 +- 우측 상단 + - 남은 친구 물주기 횟수 +- 중앙 + - 정원/아바타 비주얼 +- 우측 하단 + - 물주기 아이콘 버튼 +- 하단 + - 방명록 진입 버튼 + +### 2. 방명록 전체 화면 + +새 `GuestbookScreen`을 추가한다. + +- 전체 화면 페이지 +- 상단 헤더 +- 방명록 목록 `FlatList` +- 하단 고정 입력창 +- 댓글 UX처럼 입력창은 목록과 분리된 하단 고정 구조 + +화면 진입 경로는 두 가지다. + +1. 타인 프로필 하단 `방명록 작성` +2. 내 홈 비둘기 흐름에서 방명록 목록 보기 + +## 구현 방향 + +### A. 프로필 화면 재구성 + +새 구조 제안: + +- `ProfileScreen` + - 데이터 로딩 + - `followAction` 계산 + - `PagerView` 페이지 관리 + - 방명록 화면 이동 +- `ProfileGardenScene` + - 홈의 `HomeGardenScene` 스타일을 참고한 정원 중심 씬 + - 물주기 버튼 포함 + - 방명록 버튼 포함 + +`ProfileDetail`은 아래 둘 중 하나로 정리한다. + +1. 제거하고 `ProfileGardenScene`으로 대체 +2. 이름을 `ProfileGardenScene`으로 바꾸고 홈형 UI로 전환 + +권장: + +- 기존 `ProfileDetail`을 억지로 확장하지 말고, + `ProfileGardenScene`을 새 컴포넌트로 분리하는 편이 구조가 깔끔하다. + +### B. 방명록 페이지 추가 + +새 파일 후보: + +- `src/pages/profile/GuestbookScreen.tsx` +- `src/components/profile/GuestbookListItem.tsx` +- `src/apis/profile/guestbookApi.ts` +- `src/hooks/profile/useGuestbookApi.ts` +- `src/types/profile/guestbookApi.type.ts` + +필요 API: + +- GET `/api/v1/users/guestbook/{userId}/list` +- POST `/api/v1/users/{userId}/guestbook` + +### C. 홈 비둘기와의 관계 + +기존 `HomeAlertsModal`은 당장 제거하지 않는다. + +단계별 정리 방향: + +1. 우선 `GuestbookScreen`을 새로 만든다. +2. 타인 프로필에서 방명록 버튼으로 `GuestbookScreen` 진입을 붙인다. +3. 이후 홈 비둘기에서 방명록 탭을 눌렀을 때도 + 필요하면 `GuestbookScreen(myUserId)`로 연결할 수 있게 확장한다. + +## 작업 단위 + +### 1단계. 문서화 + +- 이 문서 추가 + +커밋 메시지: + +- `chore: 타인 프로필과 방명록 재구성 계획 문서 추가` + +### 2단계. 프로필 라우트/타입 기반 정리 + +- `ProfileScreen`이 다중 정원 배열을 사용할 수 있게 구조 변경 +- 현재 타입 기준으로 해금된 정원 수만큼 페이저 페이지 생성 +- `ProfileGardenScene` 뼈대 추가 + +커밋 메시지: + +- `refactor: 타인 프로필을 정원 페이저 구조로 재구성` + +### 3단계. 프로필 씬 홈 스타일 적용 + +- 홈과 유사한 배경형 정원 씬 적용 +- 상단 헤더 / 팔로우 버튼 / 물주기 카운트 / 액션 레일 / 방명록 버튼 반영 +- 물주기 버튼은 기존 `useFriendWater`와 연결 + +커밋 메시지: + +- `feat: 타인 프로필 정원 씬과 물주기 UI 적용` + +### 4단계. 방명록 API / 타입 / 훅 추가 + +- 방명록 목록 조회 API +- 방명록 작성 API +- react-query 훅 추가 + +커밋 메시지: + +- `feat: 방명록 조회와 작성 API 훅 추가` + +### 5단계. 방명록 전체 화면 추가 + +- `GuestbookScreen` 라우트 추가 +- 목록 스크롤 + 하단 고정 입력창 구현 +- 빈 상태 문구 처리 + +커밋 메시지: + +- `feat: 방명록 전체 화면과 입력 UX 구현` + +### 6단계. 프로필 방명록 버튼 연결 + +- 타인 프로필의 `방명록 작성` 버튼을 `GuestbookScreen(userId)`로 연결 + +커밋 메시지: + +- `feat: 타인 프로필 방명록 진입 연결` + +### 7단계. 홈 비둘기와 방명록 흐름 정리 + +- `HomeAlertsModal`에서 방명록 진입 전략 결정 +- 필요 시 내 방명록 전체 보기로 연결 + +커밋 메시지: + +- `refactor: 홈 비둘기와 방명록 조회 흐름 정리` + +## 구현 시 주의점 + +1. 타인 프로필은 홈과 비슷해도 완전히 같은 규칙을 쓰면 안 된다. + 홈은 4슬롯 고정, 타인 프로필은 해금된 정원 수만큼만 페이지 생성해야 한다. +2. 현재 프로필 API엔 정원 배경 정보가 없으므로 fallback 배경 전략이 필요하다. +3. 방명록 입력창은 댓글 시트처럼 "하단 고정"이 핵심이다. +4. 방명록 버튼은 동작 없는 더미 상태로 두지 않는다. +5. 비둘기 모달과 새 방명록 페이지가 역할 충돌하지 않도록, + 모달은 요약/알림, 화면은 읽기+작성 전체 흐름으로 분리한다. + +## 최종 기대 결과 + +구현 완료 후에는 아래가 성립해야 한다. + +1. 타인 프로필에 들어가면 홈과 유사한 정원 중심 화면이 보인다. +2. 해금된 정원 수만큼만 좌우 스와이프가 된다. +3. 친구 추가 / 맞팔 / 언팔로우 버튼이 상단에서 동작한다. +4. 우측 액션 레일의 물주기 버튼으로 실제 친구 물주기가 가능하다. +5. 하단 `방명록 작성` 버튼을 누르면 별도 전체 화면의 방명록 페이지로 이동한다. +6. 방명록 페이지에서 기존 글을 스크롤로 보고, 하단 입력창으로 새 글을 남길 수 있다. +7. 받은 유저는 홈 비둘기 알림에서도 방명록 흐름을 확인할 수 있다. diff --git a/PROFILE_SOCIAL_NICKNAME_AND_HEADER_PLAN.md b/PROFILE_SOCIAL_NICKNAME_AND_HEADER_PLAN.md new file mode 100644 index 0000000..f0e7bdd --- /dev/null +++ b/PROFILE_SOCIAL_NICKNAME_AND_HEADER_PLAN.md @@ -0,0 +1,35 @@ +# Profile And Social Nickname Plan + +## 목표 + +- 소셜 신규 가입 유저도 사람이 읽을 수 있는 닉네임을 직접 한 번 설정하게 한다. +- 타인 프로필 상단 구조를 방명록 화면과 톤이 맞는 박스형 헤더로 정리한다. + +## 현재 확인된 상태 + +- 비회원 가입은 `RegisterScreen`에서 닉네임을 입력한 뒤 가입한다. +- 소셜 로그인 신규 유저는 `RegistrationAvatar`로 바로 이동해서 닉네임 입력 단계가 없다. +- MainBE에는 `PATCH /api/v1/users/me/nickname` 엔드포인트가 이미 있다. +- 타인 프로필은 현재 정원 씬 내부에 헤더가 섞여 있어서 페이지 정체성이 약하다. + +## 작업 단계 + +### 1. 소셜 신규 유저 닉네임 설정 플로우 추가 + +- `SocialNickname` 전용 화면을 추가한다. +- 소셜 로그인 결과가 `newUser=true`면 `RegistrationAvatar`가 아니라 `SocialNickname`으로 보낸다. +- 닉네임 저장 성공 후에만 `RegistrationAvatar`로 이동시킨다. +- 이 단계는 유저 닉네임만 확정하는 단계이므로 식물 등록 상태와 분리한다. + +### 2. 타인 프로필 상단 헤더 박스형으로 리팩터링 + +- 정원 씬 바깥에 고정 상단 헤더를 둔다. +- 좌측은 뒤로가기, 가운데는 `프로필`, 우측은 친구 추가/맞팔/언팔로우를 둔다. +- 정원 씬 내부 헤더는 제거해서 역할을 분리한다. +- 물주기 횟수와 물주기 액션, 우편함, 방명록 버튼은 정원 씬에 남긴다. + +### 3. 검증 + +- 소셜 신규 유저 로그인 시 닉네임 설정 화면이 먼저 나오는지 확인한다. +- 닉네임 저장 후 `RegistrationAvatar`로 정상 이동하는지 확인한다. +- 타인 프로필에서 상단 헤더가 고정되고, 페이지 인디케이터와 씬 요소가 겹치지 않는지 확인한다. diff --git a/README.md b/README.md index 892fe40..a7918d4 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,14 @@ React Native (Expo) 기반 모바일 애플리케이션 ### 1. 사전 요구사항 ```bash -# Node.js 버전 확인 (18.x 이상 권장) +# Node.js 버전 확인 (20.19.x 이상 권장) node -v # npm 버전 확인 npm -v ``` -Node.js가 설치되어 있지 않다면 [nodejs.org](https://nodejs.org)에서 LTS 버전을 설치하세요. +Node.js 20.19.x 이상을 권장합니다. [nodejs.org](https://nodejs.org)에서 LTS 버전(20 또는 22)을 설치하세요. --- @@ -45,31 +45,37 @@ npm install ### 3. 개발 서버 실행 -이거 권장 -> WIFI 같지 않아도 됨. (규영) -npx expo start --clear --tunnel - +#### Expo Go 경로 (Apple Developer 계정 불필요) ```bash -npx expo start +# 터널 모드: PC와 iPhone이 같은 Wi-Fi가 아니어도 연결됩니다 +npx expo start --tunnel --clear ``` -실행 후 터미널에 QR 코드가 표시됩니다. +#### Development Build 경로 (native 모듈 개발 시) + +`expo-dev-client` 가 제거된 상태입니다. `expo run:android / expo run:ios` 개발 빌드가 필요한 경우 +`npx expo install expo-dev-client` 후 `app.json` plugins에 `"expo-dev-client"` 를 다시 추가하세요. --- ### 4. 앱 실행 방법 -#### 방법 A: 실제 기기 (권장) +#### 방법 A: 실제 기기 — Expo Go (권장, 계정 불필요) 1. **Expo Go 앱 설치** - Android: [Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent) - iOS: [App Store](https://apps.apple.com/app/expo-go/id982107779) -2. **QR 코드 스캔** +2. **서버 실행 후 QR 코드 스캔** + ```bash + npx expo start --tunnel --clear + ``` - Android: Expo Go 앱 내에서 직접 스캔 - iOS: 기본 카메라 앱으로 스캔 후 링크 터치 -> **주의**: PC와 휴대폰이 **같은 Wi-Fi 네트워크**에 연결되어 있어야 합니다. +> **터널 모드**: PC와 iPhone이 **같은 Wi-Fi가 아니어도** 연결됩니다. +> Expo Go에서 `Project is incompatible`가 뜨면 프로젝트 SDK와 스토어 Expo Go 지원 SDK가 맞는지 확인하세요. #### 방법 B: 에뮬레이터 (선택사항) diff --git a/SOCIAL_ONBOARDING_AND_OPTION_STATUS.md b/SOCIAL_ONBOARDING_AND_OPTION_STATUS.md new file mode 100644 index 0000000..4a32033 --- /dev/null +++ b/SOCIAL_ONBOARDING_AND_OPTION_STATUS.md @@ -0,0 +1,112 @@ +# Social Onboarding And Option Menu Status + +## 1. 소셜 신규 가입을 임시 상태 기반으로 바꾸는 백엔드 계획 + +### 목표 + +- 소셜 인증 직후에는 계정을 최소 정보로만 생성하거나 온보딩 미완료 상태로 둔다. +- 유저 닉네임이 확정되기 전에는 서비스 내 노출 이름을 정식 값으로 사용하지 않는다. +- 닉네임 저장이 끝난 뒤에만 식물 등록과 서비스 온보딩을 이어간다. + +### 현재 상태 + +- MainAPP는 소셜 신규 유저를 `SocialNickname` 화면으로 보낸다. +- 하지만 MainBE는 `/api/v1/auth/supabase` 처리 시점에 이미 사용자를 저장한다. +- 이때 `AuthService.buildUniqueNickname()`으로 임시 닉네임을 즉시 생성한다. +- 그래서 프론트가 닉네임을 받기 전에 임시 닉네임이 방명록, 프로필 등에 남을 수 있다. + +### MainBE에서 바꿔야 할 점 + +#### 1. AuthService 신규 소셜 가입 처리 + +- 파일: `domain/member/auth/service/AuthService.java` +- 현재: + - 신규 소셜 유저면 즉시 `User` 저장 + - `buildUniqueNickname()`으로 임시 닉네임 생성 +- 변경: + - 닉네임 미확정 상태를 표현하는 필드를 두고 저장 + - 또는 별도 임시 유저 상태를 둔다 + - 응답에 `requiresNicknameSetup` 같은 명시적 값을 내려준다 + +#### 2. User 엔티티 온보딩 상태 필드 + +- 파일: `domain/member/user/domain/User.java` +- 후보 필드: + - `nicknameConfirmed` + - `onboardingCompleted` +- 목적: + - 닉네임 확정 전/후를 서버가 명확히 구분 + +#### 3. 닉네임 확정 API 의미 강화 + +- 현재 엔드포인트: `PATCH /api/v1/users/me/nickname` +- 이 API를 최초 닉네임 확정에도 사용하거나, +- 별도 `POST /api/v1/users/me/onboarding/nickname` 같은 전용 엔드포인트를 둘 수 있다 + +#### 4. 서비스 접근 가드 + +- 닉네임 미확정 유저는 일부 기능 진입 전 프론트가 닉네임 화면으로 유도 +- 백엔드도 필요하면 온보딩 미완료 상태에서 특정 쓰기 기능을 제한 + +### MainAPP에서 맞춰야 할 점 + +- 소셜 로그인 성공 후 `newUser` 또는 `requiresNicknameSetup`이면 닉네임 화면으로 이동 +- 닉네임 저장 성공 후에만 `RegistrationAvatar`로 진행 +- 로그인 직후 프로필/방명록/알림 캐시를 갱신해서 임시 닉네임이 남지 않게 한다 + +### 권장 순서 + +1. MainBE에 온보딩 상태 필드 추가 +2. `/api/v1/auth/supabase` 응답에 `requiresNicknameSetup` 추가 +3. 닉네임 확정 API 의미 정리 +4. MainAPP 분기값을 `newUser` 중심에서 `requiresNicknameSetup` 중심으로 전환 + +## 2. 설정 화면 메뉴 현황 점검 + +대상 메뉴: +- 유저 닉네임 변경 +- 아바타 닉네임 변경 +- 이용 약관 +- 서비스 안내 + +### 유저 닉네임 변경 + +- Option 메뉴 행 존재: 있음 +- 전용 설정 화면: 없음 +- 관련 프론트 화면: `SocialNicknameScreen`은 존재하지만 온보딩용이고 설정 메뉴와는 미연결 +- 백엔드 API: 있음 + - `PATCH /api/v1/users/me/nickname` +- 현재 API 연결: 설정 메뉴에는 안 되어 있음 + +### 아바타 닉네임 변경 + +- Option 메뉴 행 존재: 있음 +- 전용 설정 화면: 없음 +- 백엔드 API: 있음 + - `PATCH /api/v1/users/me/{avatarId}` + - body의 `newAvatarName`으로 변경 가능 +- 현재 API 연결: 설정 메뉴에는 안 되어 있음 + +### 이용 약관 + +- Option 메뉴 행 존재: 있음 +- 전용 프론트 화면: 없음 +- 백엔드 API: 있음 + - `GET /api/v1/policy` +- 현재 API 연결: 안 되어 있음 + +### 서비스 안내 + +- Option 메뉴 행 존재: 있음 +- 전용 프론트 화면: 없음 +- 백엔드 API: 확인되지 않음 +- 현재 API 연결: 안 되어 있음 + +## 결론 + +- 설정 메뉴 4개 중 실제로 메뉴와 연결된 것은 없다. +- 백엔드 API가 확인된 것은 + - 유저 닉네임 변경 + - 아바타 닉네임 변경 + - 이용 약관 조회 +- 서비스 안내는 현재 코드 기준으로 전용 화면도 없고 백엔드 API도 보이지 않는다. diff --git a/TRACKING_REWARD_FLOW_PLAN.md b/TRACKING_REWARD_FLOW_PLAN.md new file mode 100644 index 0000000..826ecee --- /dev/null +++ b/TRACKING_REWARD_FLOW_PLAN.md @@ -0,0 +1,205 @@ +# 2주 리포트 상태 저장 및 확인 모달 계획 + +## 목표 + +- 비둘기 클릭 기본 동선은 `알림 모달(방명록/기록)`로 유지한다. +- `2주 리포트 확인 모달`은 비둘기 클릭과 분리된 별도 흐름으로 처리한다. +- 앱은 백엔드의 `perfectDay` 조건을 그대로 따른다. +- 모달은 `최근 14일 perfect day 충족` 상태가 된 직후 한 번만 뜬다. +- 사용자가 이미 확인한 같은 주기의 모달은 다시 뜨지 않는다. + +## 기준 해석 + +- `perfect day`: + - 해당 날짜에 `물 주기`와 `햇빛 주기`를 모두 완료한 날 +- `2주 리포트 대상 주기`: + - 백엔드가 계산하는 최근 14일 윈도우 기준 +- `모달 노출 시점`: + - 앱 임의 계산이 아니라 백엔드가 `이번 주기 리포트를 지금 띄워도 되는지`를 판단한 뒤 내려준다. + - 실질적으로는 `14번째 perfect day가 완성된 뒤`, 즉 당일 물/햇빛 둘 다 완료되어 조건을 만족한 직후가 된다. + +## 권장 동작 + +1. 사용자가 당일 물 또는 햇빛 중 하나를 수행한다. +2. 백엔드는 daily activity를 갱신한다. +3. 백엔드는 해당 시점에 `최근 14일 perfectDayCount`, `이번 주기 식별자`, `이미 확인했는지`를 계산한다. +4. 조건을 만족하면 앱은 홈 진입 시점 또는 홈 복귀 시점에 `2주 리포트 확인 모달`을 자동 노출한다. +5. 사용자가 모달을 닫거나 CTA를 누르면 백엔드에 `이번 주기 확인 완료`를 저장한다. +6. 같은 주기 동안은 다시 노출하지 않는다. + +## MainBE 단계별 계획 + +### 1단계: 주기 상태 응답 정의 + +- 새 응답 DTO 추가 + - 예시: `TrackingPromptStatusResponse` +- 포함 필드 + - `eligible`: 지금 모달 노출 대상인지 + - `perfectDayCount`: 최근 14일 perfect day 수 + - `windowStart` + - `windowEnd` + - `cycleKey`: 이번 2주 주기를 식별하는 값 + - `alreadyViewed`: 이번 주기를 이미 확인했는지 + - `message`: 기존 리포트 문구 재사용 가능 + +### 2단계: 확인 이력 저장 모델 추가 + +- 새 엔티티 추가 권장 + - 예시: `TrackingReportView` +- 필드 예시 + - `id` + - `user` + - `cycleKey` + - `viewedAt` +- 제약 조건 + - `user + cycleKey` unique + +### 3단계: 상태 조회 API 추가 + +- 예시 + - `GET /api/v1/tracking/report/status` +- 역할 + - 최근 14일 perfect day 계산 + - 현재 주기 식별 + - 이번 주기 확인 여부 확인 + - `eligible = perfectDayCount >= 14 && alreadyViewed == false` + +### 4단계: 확인 완료 API 추가 + +- 예시 + - `POST /api/v1/tracking/report/confirm` +- 요청값 + - `cycleKey` +- 역할 + - 사용자가 이번 주기 리포트를 확인했다는 상태 저장 + +### 5단계: 기존 activity 기록 로직과 연결 + +- 현재 `GardenService`의 일일 활동 기록 로직은 유지 +- 별도 복잡한 실시간 push 없이도 충분 +- 핵심은 홈에서 상태 조회 시 `eligible`을 정확히 계산하는 것 + +### 6단계: 판단 기준 단일화 + +- 앱은 `praiseDayCount >= 14`만 보고 띄우지 않는다. +- 백엔드 `eligible`만 신뢰한다. +- 이렇게 해야 다음 문제를 막을 수 있다. + - 같은 주기 재노출 + - 홈 재진입 때 중복 노출 + - 클라이언트와 서버 계산 불일치 + +## MainAPP 단계별 계획 + +### 1단계: 비둘기 동선 유지 + +- 현재 비둘기 클릭: + - `알림 모달(방명록/기록)` +- 유지 +- 2주 리포트 모달은 비둘기 클릭에서 열지 않는다. + +### 2단계: 홈 진입 시 상태 조회 + +- 홈 API와 별도 query 또는 home-summary 확장 중 하나 선택 +- 권장 + - 별도 query: `useTrackingPromptStatus` +- 조회 시점 + - 홈 첫 진입 + - 물/햇빛 액션 성공 후 invalidate + - 홈 화면 복귀 시 refetch + +### 3단계: 자동 노출 조건 + +- 앱 조건 + - `visibleCandidate = status.eligible === true` +- 자동 노출 위치 + - `HomeScreen` +- 추가 가드 + - 같은 앱 세션에서 이미 연 경우 local state로 중복 open 방지 + +### 4단계: 확인 모달 컴포넌트 연결 + +- 기존 `HomeTrackingModal` 재사용 또는 분리 +- 권장 + - 현재 `HomeTrackingModal`을 `2주 리포트 확인 모달` 전용으로 유지 +- 표시 내용 + - `perfectDayCount` + - 백엔드 `message` + - 확인 CTA + +### 5단계: 확인 완료 처리 + +- 사용자가 모달 닫기 또는 CTA 누를 때 + - `POST /api/v1/tracking/report/confirm` +- 성공 후 + - status query invalidate + - 모달 닫기 + +### 6단계: 물/햇빛 액션과의 연결 + +- 현재 `postGardenSunlight`, `postGardenMyWater` 성공 시 홈 query만 invalidate 중 +- 이후 추가 + - `tracking-report-status` invalidate +- 이유 + - 2주째 되는 날 당일 두 액션을 모두 완료한 직후, + 홈에 돌아왔을 때 곧바로 모달 노출 가능해야 함 + +## 추천 API 계약 + +### 상태 조회 응답 예시 + +```json +{ + "isSuccess": true, + "code": "COMMON200", + "message": "성공입니다.", + "result": { + "eligible": true, + "alreadyViewed": false, + "perfectDayCount": 14, + "cycleKey": "2026-03-13", + "windowStart": "2026-02-28", + "windowEnd": "2026-03-13", + "message": "2주 동안 꾸준히 돌봐주셨어요." + } +} +``` + +### 확인 완료 요청 예시 + +```json +{ + "cycleKey": "2026-03-13" +} +``` + +## UX 기준 + +- 비둘기: + - 항상 알림 모달 +- 2주 리포트: + - 조건 충족 직후 홈에서 자동 팝업 +- 이미 본 같은 주기 리포트: + - 다시 자동 팝업 금지 +- 다음 주기: + - 새 `cycleKey`에서 다시 가능 + +## 구현 순서 + +1. MainBE + - 상태 조회 DTO/API 추가 + - 확인 이력 저장 테이블/엔티티 추가 + - 확인 완료 API 추가 +2. MainAPP + - 상태 query 추가 + - 홈 자동 노출 로직 추가 + - 확인 완료 mutation 추가 + - 물/햇빛 성공 후 상태 query invalidate 추가 +3. QA + - 14일 미만: 모달 미노출 + - 14일 달성 직후: 1회 노출 + - 확인 후 홈 재진입: 재노출 없음 + - 다음 주기 달성: 다시 노출 + +## 메모 + +- 다음 커밋에서는 현재 워킹트리에 남아 있는 `src/assets/icons/sun.svg`, `src/assets/icons/water.svg`도 함께 정리해서 포함한다. diff --git a/app.json b/app.json index acd9f36..5a2a2b9 100644 --- a/app.json +++ b/app.json @@ -2,11 +2,12 @@ "expo": { "name": "hanium-app", "slug": "hanium-app", + "scheme": "haniumapp", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", - "newArchEnabled": false, + "newArchEnabled": true, "splash": { "image": "./assets/splash-icon.png", "resizeMode": "contain", @@ -21,10 +22,28 @@ "backgroundColor": "#ffffff" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "package": "com.anonymous.haniumapp" }, "web": { "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-secure-store", + "expo-web-browser", + [ + "expo-notifications", + { + "icon": "./assets/icon.png", + "color": "#3AB40B" + } + ], + "expo-font" + ], + "extra": { + "eas": { + "projectId": "4a20c3ca-b299-4de4-9464-1e29e97df171" + } } } } diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png index 03d6f6b..f690d82 100644 Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ diff --git a/assets/favicon.png b/assets/favicon.png index e75f697..f690d82 100644 Binary files a/assets/favicon.png and b/assets/favicon.png differ diff --git a/assets/icon.png b/assets/icon.png index a0b1526..f690d82 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..f690d82 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/refresh-icon.png b/assets/refresh-icon.png new file mode 100644 index 0000000..6017e98 Binary files /dev/null and b/assets/refresh-icon.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png index 03d6f6b..f690d82 100644 Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ diff --git a/babel.config.js b/babel.config.js index cbccaaf..6139352 100644 --- a/babel.config.js +++ b/babel.config.js @@ -12,6 +12,7 @@ module.exports = function (api) { }, }, ], + "react-native-reanimated/plugin", ], }; }; diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..a68ec99 --- /dev/null +++ b/eas.json @@ -0,0 +1,29 @@ +{ + "cli": { + "version": ">= 16.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "android": { + "buildType": "app-bundle" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/index.ts b/index.ts index 1d6e981..ff163fa 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,4 @@ +import "react-native-gesture-handler"; import { registerRootComponent } from 'expo'; import App from './App'; diff --git a/metro.config.js b/metro.config.js index b0963fe..8d1f74c 100644 --- a/metro.config.js +++ b/metro.config.js @@ -3,4 +3,17 @@ const { withNativeWind } = require("nativewind/metro"); const config = getDefaultConfig(__dirname); +const { transformer, resolver } = config; + +config.transformer = { + ...transformer, + babelTransformerPath: require.resolve("react-native-svg-transformer") +}; + +config.resolver = { + ...resolver, + assetExts: resolver.assetExts.filter((ext) => ext !== "svg"), + sourceExts: [...resolver.sourceExts, "svg"] +}; + module.exports = withNativeWind(config, { input: "./global.css" }); diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts index a13e313..0270208 100644 --- a/nativewind-env.d.ts +++ b/nativewind-env.d.ts @@ -1 +1,9 @@ /// + +declare module "*.svg" { + import * as React from "react"; + import type { SvgProps } from "react-native-svg"; + + const content: React.FC; + export default content; +} diff --git a/package-lock.json b/package-lock.json index 3a0de91..648c610 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,30 +8,48 @@ "name": "hanium-app", "version": "1.0.0", "dependencies": { + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.10.1", "@react-navigation/native": "^7.1.28", "@react-navigation/native-stack": "^7.10.1", "@react-navigation/stack": "^7.6.16", + "@supabase/supabase-js": "^2.98.0", "@tanstack/react-query": "^5.90.19", "axios": "^1.13.2", - "babel-preset-expo": "~54.0.9", - "expo": "~54.0.31", + "babel-preset-expo": "~54.0.10", + "expo": "~54.0.33", + "expo-auth-session": "~7.0.10", + "expo-crypto": "~15.0.8", + "expo-device": "~8.0.10", + "expo-font": "~14.0.11", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.16", + "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.10", "nativewind": "^4.2.1", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", "react-native-pager-view": "6.9.1", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", + "react-native-url-polyfill": "^3.0.0", + "react-native-web": "^0.21.0", + "react-native-worklets": "0.5.1", "tailwindcss": "^3.4.17", "zustand": "^5.0.10" }, "devDependencies": { + "@expo/ngrok": "^4.1.3", "@types/react": "~19.1.0", "babel-plugin-module-resolver": "^5.0.2", + "react-native-svg-transformer": "^1.5.3", "typescript": "~5.9.2" } }, @@ -62,38 +80,43 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.10.4" + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -109,37 +132,14 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -176,15 +176,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", @@ -206,15 +197,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", @@ -232,26 +214,17 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" + "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -446,13 +419,84 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -462,9 +506,9 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.6.tgz", - "integrity": "sha512-RVdFPPyY9fCRAX68haPmOk2iyKW8PKJFthmm8NeSI3paNxKWGZIn99+VbIf0FrtCpFnPgnpF/L48tadi617ULg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -788,14 +832,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", - "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -1031,13 +1075,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1270,9 +1314,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", - "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1285,13 +1329,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.5.tgz", - "integrity": "sha512-20NUVgOrinudkIBzQ2bNxP08YpKprUkRTiRSd2/Z5GOdPImJGkoN4Z7IQe1T5AdyKI1i5L6RBmluqdSzvaq9/w==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", @@ -1304,15 +1348,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", @@ -1364,7 +1399,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1472,32 +1506,18 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -1506,55 +1526,27 @@ }, "node_modules/@babel/traverse--for-generate-function-map": { "name": "@babel/traverse", - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse--for-generate-function-map/node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1569,7 +1561,6 @@ "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", "license": "MIT", - "peer": true, "dependencies": { "@types/hammerjs": "^2.0.36" }, @@ -1629,273 +1620,270 @@ "xml2js": "0.6.0" } }, - "node_modules/@expo/config-plugins/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@expo/config-plugins/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/config-plugins/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", + "node_modules/@expo/config-plugins/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/config-plugins/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", + "node_modules/@expo/config-plugins/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-name": "~1.1.4" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=7.0.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/config-plugins/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/config-plugins/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", + "node_modules/@expo/config-plugins/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@expo/config-plugins/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", + "node_modules/@expo/config-plugins/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", "dependencies": { - "has-flag": "^4.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/config-types": { - "version": "54.0.10", + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/config-types": { + "version": "54.0.10", "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", "license": "MIT" }, - "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", - "license": "MIT", - "dependencies": { - "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0" - } - }, - "node_modules/@expo/devcert/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/@expo/config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "@babel/highlight": "^7.10.4" } }, - "node_modules/@expo/devtools": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", - "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", + "node_modules/@expo/config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-native": { - "optional": true - } + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/devtools/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@expo/config/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/devtools/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", + "node_modules/@expo/config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/devtools/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/devtools/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/devtools/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", + "node_modules/@expo/config/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": "20 || >=22" } }, - "node_modules/@expo/devtools/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", + "node_modules/@expo/config/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "has-flag": "^4.0.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/env": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz", - "integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==", - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0" + "node_modules/@expo/config/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@expo/env/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", + "node_modules/@expo/config/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-convert": "^2.0.1" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/env/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "node_modules/@expo/config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@expo/env/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" } }, - "node_modules/@expo/env/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/env/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/@expo/env/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@expo/devtools": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", + "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "chalk": "^4.1.2" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/env": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.11.tgz", + "integrity": "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" } }, "node_modules/@expo/fingerprint": { @@ -1920,171 +1908,139 @@ "fingerprint": "bin/cli.js" } }, - "node_modules/@expo/fingerprint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@expo/fingerprint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/fingerprint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", + "node_modules/@expo/fingerprint/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/fingerprint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", + "node_modules/@expo/fingerprint/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-name": "~1.1.4" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=7.0.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/fingerprint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/fingerprint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", + "node_modules/@expo/fingerprint/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": "20 || >=22" } }, - "node_modules/@expo/fingerprint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, + "node_modules/@expo/fingerprint/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@expo/image-utils": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz", - "integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==", - "license": "MIT", + "node_modules/@expo/fingerprint/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", "dependencies": { - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "resolve-from": "^5.0.0", - "resolve-global": "^1.0.0", - "semver": "^7.6.0", - "temp-dir": "~2.0.0", - "unique-string": "~2.0.0" - } - }, - "node_modules/@expo/image-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/image-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@expo/image-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@expo/image-utils": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.13.tgz", + "integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@expo/image-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/image-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "@expo/require-utils": "^55.0.4", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" } }, - "node_modules/@expo/image-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=8" + "node": ">=10" } }, "node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.13.tgz", + "integrity": "sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", + "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, @@ -2110,101 +2066,356 @@ "metro-transform-worker": "0.83.3" } }, - "node_modules/@expo/osascript": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz", - "integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==", + "node_modules/@expo/metro-config": { + "version": "54.0.14", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz", + "integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==", "license": "MIT", "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8", + "@expo/json-file": "~10.0.8", + "@expo/metro": "~54.2.0", "@expo/spawn-async": "^1.7.2", - "exec-async": "^2.2.0" + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.29.1", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "minimatch": "^9.0.0", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/metro-config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/package-manager": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz", - "integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==", + "node_modules/@expo/metro-config/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.0.8", - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "resolve-workspace-root": "^2.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/package-manager/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", + "node_modules/@expo/metro-config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-convert": "^2.0.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/package-manager/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", + "node_modules/@expo/metro-config/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/package-manager/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", + "node_modules/@expo/metro-config/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@expo/metro-config/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@expo/metro-config/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-name": "~1.1.4" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=7.0.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/package-manager/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" + "node_modules/@expo/ngrok": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@expo/ngrok/-/ngrok-4.1.3.tgz", + "integrity": "sha512-AESYaROGIGKWwWmUyQoUXcbvaUZjmpecC5buArXxYou+RID813F8T0Y5jQ2HUY49mZpYfJiy9oh4VSN37GgrXA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@expo/ngrok-bin": "2.3.42", + "got": "^11.5.1", + "uuid": "^3.3.2", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10.19.0" + } }, - "node_modules/@expo/package-manager/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@expo/ngrok-bin": { + "version": "2.3.42", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin/-/ngrok-bin-2.3.42.tgz", + "integrity": "sha512-kyhORGwv9XpbPeNIrX6QZ9wDVCDOScyTwxeS+ScNmUqYoZqD9LRmEqF7bpDh5VonTsrXgWrGl7wD2++oSHcaTQ==", + "dev": true, + "bin": { + "ngrok": "bin/ngrok.js" + }, + "optionalDependencies": { + "@expo/ngrok-bin-darwin-arm64": "2.3.41", + "@expo/ngrok-bin-darwin-x64": "2.3.41", + "@expo/ngrok-bin-freebsd-ia32": "2.3.41", + "@expo/ngrok-bin-freebsd-x64": "2.3.41", + "@expo/ngrok-bin-linux-arm": "2.3.41", + "@expo/ngrok-bin-linux-arm64": "2.3.41", + "@expo/ngrok-bin-linux-ia32": "2.3.41", + "@expo/ngrok-bin-linux-x64": "2.3.41", + "@expo/ngrok-bin-sunos-x64": "2.3.41", + "@expo/ngrok-bin-win32-ia32": "2.3.41", + "@expo/ngrok-bin-win32-x64": "2.3.41" + } + }, + "node_modules/@expo/ngrok-bin-darwin-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-arm64/-/ngrok-bin-darwin-arm64-2.3.41.tgz", + "integrity": "sha512-TPf95xp6SkvbRONZjltTOFcCJbmzAH7lrQ36Dv+djrOckWGPVq4HCur48YAeiGDqspmFEmqZ7ykD5c/bDfRFOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-darwin-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-x64/-/ngrok-bin-darwin-x64-2.3.41.tgz", + "integrity": "sha512-29QZHfX4Ec0p0pQF5UrqiP2/Qe7t2rI96o+5b8045VCEl9AEAKHceGuyo+jfUDR4FSQBGFLSDb06xy8ghL3ZYA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-ia32/-/ngrok-bin-freebsd-ia32-2.3.41.tgz", + "integrity": "sha512-YYXgwNZ+p0aIrwgb+1/RxJbsWhGEzBDBhZulKg1VB7tKDAd2C8uGnbK1rOCuZy013iOUsJDXaj9U5QKc13iIXw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-x64/-/ngrok-bin-freebsd-x64-2.3.41.tgz", + "integrity": "sha512-1Ei6K8BB+3etmmBT0tXYC4dyVkJMigT4ELbRTF5jKfw1pblqeXM9Qpf3p8851PTlH142S3bockCeO39rSkOnkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm/-/ngrok-bin-linux-arm-2.3.41.tgz", + "integrity": "sha512-B6+rW/+tEi7ZrKWQGkRzlwmKo7c1WJhNODFBSgkF/Sj9PmmNhBz67mer91S2+6nNt5pfcwLLd61CjtWfR1LUHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm64/-/ngrok-bin-linux-arm64-2.3.41.tgz", + "integrity": "sha512-eC8GA/xPcmQJy4h+g2FlkuQB3lf5DjITy8Y6GyydmPYMByjUYAGEXe0brOcP893aalAzRqbNOAjSuAw1lcCLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-ia32/-/ngrok-bin-linux-ia32-2.3.41.tgz", + "integrity": "sha512-w5Cy31wSz4jYnygEHS7eRizR1yt8s9TX6kHlkjzayIiRTFRb2E1qD2l0/4T2w0LJpBjM5ZFPaaKqsNWgCUIEow==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-x64/-/ngrok-bin-linux-x64-2.3.41.tgz", + "integrity": "sha512-LcU3MbYHv7Sn2eFz8Yzo2rXduufOvX1/hILSirwCkH+9G8PYzpwp2TeGqVWuO+EmvtBe6NEYwgdQjJjN6I4L1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-sunos-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-sunos-x64/-/ngrok-bin-sunos-x64-2.3.41.tgz", + "integrity": "sha512-bcOj45BLhiV2PayNmLmEVZlFMhEiiGpOr36BXC0XSL+cHUZHd6uNaS28AaZdz95lrRzGpeb0hAF8cuJjo6nq4g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ] + }, + "node_modules/@expo/ngrok-bin-win32-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-ia32/-/ngrok-bin-win32-ia32-2.3.41.tgz", + "integrity": "sha512-0+vPbKvUA+a9ERgiAknmZCiWA3AnM5c6beI+51LqmjKEM4iAAlDmfXNJ89aAbvZMUtBNwEPHzJHnaM4s2SeBhA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok-bin-win32-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-x64/-/ngrok-bin-win32-x64-2.3.41.tgz", + "integrity": "sha512-mncsPRaG462LiYrM8mQT8OYe3/i44m3N/NzUeieYpGi8+pCOo8TIC23kR9P93CVkbM9mmXsy3X6hq91a8FWBdA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/@expo/ngrok/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/@expo/package-manager/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@expo/osascript": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", + "integrity": "sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@expo/spawn-async": "^1.7.2" }, "engines": { - "node": ">=8" + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.4.tgz", + "integrity": "sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.0.13", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" } }, "node_modules/@expo/plist": { @@ -2218,6 +2429,58 @@ "xmlbuilder": "^15.1.1" } }, + "node_modules/@expo/prebuild-config": { + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", + "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/require-utils": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.4.tgz", + "integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@expo/schema-utils": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", @@ -2248,6 +2511,17 @@ "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", "license": "MIT" }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/ws-tunnel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", @@ -2255,99 +2529,24 @@ "license": "MIT" }, "node_modules/@expo/xcpretty": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.3.2.tgz", - "integrity": "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.3.tgz", + "integrity": "sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==", "license": "BSD-3-Clause", "dependencies": { - "@babel/code-frame": "7.10.4", + "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", - "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, - "bin": { - "excpretty": "build/cli.js" - } - }, - "node_modules/@expo/xcpretty/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/xcpretty/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@expo/xcpretty/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/xcpretty/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@expo/xcpretty/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/xcpretty/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "excpretty": "build/cli.js" } }, - "node_modules/@expo/xcpretty/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } + "node_modules/@expo/xcpretty/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/@expo/xcpretty/node_modules/js-yaml": { "version": "4.1.1", @@ -2361,68 +2560,50 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@expo/xcpretty/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@expo/xcpretty/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/@gorhom/bottom-sheet": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz", + "integrity": "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA==", "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "@gorhom/portal": "1.0.14", + "invariant": "^2.2.4" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@types/react": "*", + "@types/react-native": "*", + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.16.1", + "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-native": { + "optional": true + } } }, - "node_modules/@expo/xcpretty/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@gorhom/portal": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", + "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "nanoid": "^3.3.1" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" + "peerDependencies": { + "react": "*", + "react-native": "*" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", @@ -2436,6 +2617,15 @@ "node": ">=18.0.0" } }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -2470,6 +2660,67 @@ "node": ">=6" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2561,76 +2812,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/transform/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/transform/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/transform/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@jest/transform/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/transform/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", @@ -2648,76 +2829,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/types/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/types/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@jest/types/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2901,6 +3012,15 @@ "@babel/core": "*" } }, + "node_modules/@react-native/babel-preset/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@react-native/codegen": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", @@ -2923,9 +3043,9 @@ } }, "node_modules/@react-native/codegen/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2936,7 +3056,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -2953,25 +3073,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@react-native/codegen/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/@react-native/codegen/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" - } - }, "node_modules/@react-native/codegen/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -3010,6 +3115,18 @@ } } }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@react-native/debugger-frontend": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", @@ -3074,41 +3191,18 @@ "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", "license": "MIT" }, - "node_modules/@react-native/virtualized-lists": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", - "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", - "license": "MIT", - "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@types/react": "^19.1.0", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.10.1.tgz", - "integrity": "sha512-MirOzKEe/rRwPSE9HMrS4niIo0LyUhewlvd01TpzQ1ipuXjH2wJbzAM9gS/r62zriB6HMHz2OY6oIRduwQJtTw==", + "version": "7.15.5", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.5.tgz", + "integrity": "sha512-wQHredlCrRmShWQ1vF4HUcLdaiJ8fUgnbaeQH7BJ7MQVQh4mdzab0IOY/4QSmUyNRB350oyu1biTycyQ5FKWMQ==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.5", + "@react-navigation/elements": "^2.9.10", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.1.33", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3116,9 +3210,9 @@ } }, "node_modules/@react-navigation/core": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.14.0.tgz", - "integrity": "sha512-tMpzskBzVp0E7CRNdNtJIdXjk54Kwe/TF9ViXAef+YFM1kSfGv4e/B2ozfXE+YyYgmh4WavTv8fkdJz1CNyu+g==", + "version": "7.17.2", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.2.tgz", + "integrity": "sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==", "license": "MIT", "dependencies": { "@react-navigation/routers": "^7.5.3", @@ -3131,154 +3225,477 @@ "use-sync-external-store": "^1.5.0" }, "peerDependencies": { - "react": ">= 18.2.0" + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.15.tgz", + "integrity": "sha512-cyz/pPiyyC6gaTVLsGFc1g0MYgrmuCFqklAWGXMWPscr5YU3ui94vPI4vnZwcsEy0T758TQWLzmS5XudZeRKcA==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.2.2", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz", + "integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.17.2", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.5.tgz", + "integrity": "sha512-NuyMf21kKk3jODvYgpcDA+HwyWr/KEj72ciqquyEupZlsmQ3WNUGgdaixEB3A19+iPOvHLQzDLcoTrrqZk8Leg==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.10", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.33", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", + "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, + "node_modules/@react-navigation/stack": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.8.5.tgz", + "integrity": "sha512-ZOD1gUhWpbI+1PD5mKZFnLBh3Vfq2bqhO5/NeEruaQwNdXkiiHpi59OUKMnFRQURjjYXf/skTM9hJa6zHdiyFw==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.10", + "color": "^4.2.3", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.33", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-gesture-handler": ">= 2.0.0", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.1.tgz", + "integrity": "sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.1.tgz", + "integrity": "sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.1.tgz", + "integrity": "sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.1.tgz", + "integrity": "sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.1.tgz", + "integrity": "sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.1.tgz", + "integrity": "sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.99.1", + "@supabase/functions-js": "2.99.1", + "@supabase/postgrest-js": "2.99.1", + "@supabase/realtime-js": "2.99.1", + "@supabase/storage-js": "2.99.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@react-navigation/core/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@react-navigation/core/node_modules/react-is": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", - "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", - "license": "MIT" - }, - "node_modules/@react-navigation/elements": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.5.tgz", - "integrity": "sha512-iHZU8rRN1014Upz73AqNVXDvSMZDh5/ktQ1CMe21rdgnOY79RWtHHBp9qOS3VtqlUVYGkuX5GEw5mDt4tKdl0g==", + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, "license": "MIT", - "dependencies": { - "color": "^4.2.3", - "use-latest-callback": "^0.2.4", - "use-sync-external-store": "^1.5.0" + "engines": { + "node": ">=14" }, - "peerDependencies": { - "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.28", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" }, - "peerDependenciesMeta": { - "@react-native-masked-view/masked-view": { - "optional": true - } + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@react-navigation/native": { - "version": "7.1.28", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz", - "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==", + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, "license": "MIT", - "dependencies": { - "@react-navigation/core": "^7.14.0", - "escape-string-regexp": "^4.0.0", - "fast-deep-equal": "^3.1.3", - "nanoid": "^3.3.11", - "use-latest-callback": "^0.2.4" + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" }, "peerDependencies": { - "react": ">= 18.2.0", - "react-native": "*" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@react-navigation/native-stack": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.10.1.tgz", - "integrity": "sha512-8jt7olKysn07HuKKSjT/ahZZTV+WaZa96o9RI7gAwh7ATlUDY02rIRttwvCyjovhSjD9KCiuJ+Hd4kwLidHwJw==", + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.5", - "color": "^4.2.3", - "sf-symbols-typescript": "^2.1.0", - "warn-once": "^0.1.1" + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" }, "peerDependencies": { - "@react-navigation/native": "^7.1.28", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@react-navigation/native/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, "engines": { - "node": ">=10" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@react-navigation/routers": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", - "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, "license": "MIT", "dependencies": { - "nanoid": "^3.3.11" + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@react-navigation/stack": { - "version": "7.6.16", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.6.16.tgz", - "integrity": "sha512-kYmogwU2jpxmjIrGO2P9PJCwgBHiju7OdItRkhFEHHppwU4jJQx/ViJtJ9ib3G4pqpurFdmqn1YsVB4bf7Zmxw==", + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.5", - "color": "^4.2.3", - "use-latest-callback": "^0.2.4" + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" }, "peerDependencies": { - "@react-navigation/native": "^7.1.28", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-gesture-handler": ">= 2.0.0", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" + "@svgr/core": "*" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "license": "BSD-3-Clause", + "node_modules/@svgr/plugin-svgo": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", + "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", + "dev": true, + "license": "MIT", "dependencies": { - "type-detect": "4.0.8" + "cosmiconfig": "^8.1.3", + "deepmerge": "^4.3.1", + "svgo": "^3.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "license": "BSD-3-Clause", + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" } }, "node_modules/@tanstack/query-core": { - "version": "5.90.19", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz", - "integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -3286,12 +3703,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.19", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz", - "integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==", + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.19" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -3342,6 +3759,19 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3355,8 +3785,14 @@ "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -3382,15 +3818,31 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { - "version": "25.0.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", - "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", @@ -3401,12 +3853,31 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3452,9 +3923,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3486,9 +3957,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3549,15 +4020,18 @@ } }, "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/any-promise": { @@ -3606,6 +4080,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -3618,106 +4105,51 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/babel-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/babel-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" } }, - "node_modules/babel-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" } }, "node_modules/babel-plugin-istanbul": { @@ -3765,108 +4197,20 @@ "resolve": "^1.22.8" } }, - "node_modules/babel-plugin-module-resolver/node_modules/glob": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/babel-plugin-module-resolver/node_modules/minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", @@ -3881,12 +4225,12 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3916,21 +4260,6 @@ "hermes-parser": "0.29.1" } }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" - } - }, "node_modules/babel-plugin-transform-flow-enums": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", @@ -3967,9 +4296,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "54.0.9", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.9.tgz", - "integrity": "sha512-8J6hRdgEC2eJobjoft6mKJ294cLxmi3khCUy2JJQp4htOYYkllSLUq6vudWJkTJiIuGdVR4bR6xuz2EvJLWHNg==", + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz", + "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -4025,6 +4354,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4052,12 +4387,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/better-opn": { @@ -4239,6 +4577,53 @@ "node": ">= 0.8" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4252,6 +4637,32 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -4274,9 +4685,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", "funding": [ { "type": "opencollective", @@ -4294,17 +4705,19 @@ "license": "CC-BY-4.0" }, "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chokidar": { @@ -4370,18 +4783,6 @@ "node": ">=12.13.0" } }, - "node_modules/chrome-launcher/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -4396,18 +4797,6 @@ "rimraf": "^3.0.2" } }, - "node_modules/chromium-edge-launcher/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ci-info": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", @@ -4461,6 +4850,19 @@ "node": ">=0.8" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -4475,31 +4877,6 @@ } }, "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/color/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -4511,12 +4888,22 @@ "node": ">=7.0.0" } }, - "node_modules/color/node_modules/color-name": { + "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4539,13 +4926,12 @@ } }, "node_modules/comment-json": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.1.tgz", - "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { @@ -4649,23 +5035,73 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", - "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.0" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -4681,13 +5117,13 @@ "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "hyphenate-style-name": "^1.0.3" } }, "node_modules/css-select": { @@ -4752,6 +5188,42 @@ "node": ">=4" } }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4785,6 +5257,35 @@ "node": ">=0.10" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4815,6 +5316,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -4824,6 +5352,23 @@ "node": ">=8" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4928,6 +5473,17 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4976,9 +5532,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -4996,6 +5552,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5017,6 +5583,16 @@ "node": ">=8" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -5087,12 +5663,15 @@ "license": "MIT" }, "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", "engines": { - "node": ">=0.8.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/esprima": { @@ -5126,33 +5705,27 @@ "node": ">=6" } }, - "node_modules/exec-async": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", - "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", - "license": "MIT" - }, "node_modules/expo": { - "version": "54.0.31", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.31.tgz", - "integrity": "sha512-kQ3RDqA/a59I7y+oqQGyrPbbYlgPMUdKBOgvFLpoHbD2bCM+F75i4N0mUijy7dG5F/CUCu2qHmGGUCXBbMDkCg==", + "version": "54.0.33", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", + "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.21", + "@expo/cli": "54.0.23", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", - "@expo/metro-config": "54.0.13", + "@expo/metro-config": "54.0.14", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~54.0.9", + "babel-preset-expo": "~54.0.10", "expo-asset": "~12.0.12", "expo-constants": "~18.0.13", "expo-file-system": "~19.0.21", - "expo-font": "~14.0.10", + "expo-font": "~14.0.11", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.24", "expo-modules-core": "3.0.29", @@ -5184,6 +5757,167 @@ } } }, + "node_modules/expo-application": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-asset": { + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", + "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.12" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-auth-session": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz", + "integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==", + "license": "MIT", + "dependencies": { + "expo-application": "~7.0.8", + "expo-constants": "~18.0.11", + "expo-crypto": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-web-browser": "~15.0.10", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-crypto": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz", + "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", + "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/expo-file-system": { + "version": "19.0.21", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", + "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "14.0.11", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", + "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", + "integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-keep-awake": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", + "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-linking": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", @@ -5198,20 +5932,6 @@ "react-native": "*" } }, - "node_modules/expo-linking/node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", - "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", @@ -5228,87 +5948,46 @@ "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, - "node_modules/expo-modules-autolinking/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/expo-modules-autolinking/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/expo-modules-autolinking/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/expo-modules-core": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", + "integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "invariant": "^2.2.4" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/expo-modules-autolinking/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/expo-modules-autolinking/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "peerDependencies": { + "react": "*", + "react-native": "*" } }, - "node_modules/expo-modules-autolinking/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/expo-notifications": { + "version": "0.32.16", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", + "integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@expo/image-utils": "^0.8.8", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" } }, - "node_modules/expo-modules-core": { - "version": "3.0.29", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", - "integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", "license": "MIT", - "dependencies": { - "invariant": "^2.2.4" - }, "peerDependencies": { - "react": "*", - "react-native": "*" + "expo": "*" } }, "node_modules/expo-server": { @@ -5333,24 +6012,20 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "node_modules/expo-web-browser": { + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", + "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "expo": "*", + "react-native": "*" } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.21", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.21.tgz", - "integrity": "sha512-L/FdpyZDsg/Nq6xW6kfiyF9DUzKfLZCKFXEVZcDqCNar6bXxQVotQyvgexRvtUF5nLinuT/UafLOdC3FUALUmA==", + "version": "54.0.23", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", + "integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", @@ -5362,9 +6037,9 @@ "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@expo/metro": "~54.2.0", - "@expo/metro-config": "~54.0.13", + "@expo/metro-config": "~54.0.14", "@expo/osascript": "^2.3.8", - "@expo/package-manager": "^1.9.9", + "@expo/package-manager": "^1.9.10", "@expo/plist": "^0.4.8", "@expo/prebuild-config": "^54.0.8", "@expo/schema-utils": "^0.1.8", @@ -5434,104 +6109,25 @@ } } }, - "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/prebuild-config": { - "version": "54.0.8", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", - "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", - "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.13", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/image-utils": "^0.8.8", - "@expo/json-file": "^10.0.8", - "@react-native/normalize-colors": "0.81.5", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo/node_modules/@expo/metro-config": { - "version": "54.0.13", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.13.tgz", - "integrity": "sha512-RRufMCgLR2Za1WGsh02OatIJo5qZFt31yCnIOSfoubNc3Qqe92Z41pVsbrFnmw5CIaisv1NgdBy05DHe7pEyuw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8", - "@expo/json-file": "~10.0.8", - "@expo/metro": "~54.2.0", - "@expo/spawn-async": "^1.7.2", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.29.1", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "minimatch": "^9.0.0", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "expo": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "node_modules/expo/node_modules/@expo/vector-icons": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.3.tgz", - "integrity": "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA==", - "license": "MIT", - "peerDependencies": { - "expo-font": ">=14.0.4", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/expo/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/expo/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/expo/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "18 || 20 || >=22" } }, "node_modules/expo/node_modules/ci-info": { @@ -5549,115 +6145,76 @@ "node": ">=8" } }, - "node_modules/expo/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", + "node_modules/expo/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-name": "~1.1.4" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/expo/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/expo/node_modules/expo-asset": { - "version": "12.0.12", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", - "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.8", - "expo-constants": "~18.0.12" + "node": "18 || 20 || >=22" }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/expo/node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", - "license": "MIT", + "node_modules/expo/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" + "brace-expansion": "^5.0.5" }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "19.0.21", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", - "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-font": { - "version": "14.0.10", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", - "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", - "license": "MIT", - "dependencies": { - "fontfaceobserver": "^2.1.0" + "engines": { + "node": "18 || 20 || >=22" }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/expo/node_modules/expo-keep-awake": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", - "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" + "node_modules/expo/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, - "node_modules/expo/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", + "node_modules/expo/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/expo/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/expo/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", + "node_modules/expo/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", "dependencies": { - "hermes-estree": "0.29.1" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/expo/node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz", + "integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==", "license": "MIT", "engines": { "node": ">=10" @@ -5666,37 +6223,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/expo/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/expo/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/expo/node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "node_modules/expo/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "engines": { + "node": ">=10" } }, "node_modules/exponential-backoff": { @@ -5763,6 +6308,36 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5828,16 +6403,16 @@ } }, "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "locate-path": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/flow-enums-runtime": { @@ -5872,6 +6447,21 @@ "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", "license": "BSD-2-Clause" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -5935,6 +6525,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5999,6 +6598,22 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/getenv": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", @@ -6009,17 +6624,20 @@ } }, "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "license": "BlueOak-1.0.0", + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6038,32 +6656,21 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "license": "BlueOak-1.0.0", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", - "license": "MIT", - "dependencies": { - "ini": "^1.3.4" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6076,6 +6683,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6083,12 +6716,24 @@ "license": "ISC" }, "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-symbols": { @@ -6131,18 +6776,18 @@ } }, "node_modules/hermes-estree": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", - "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", "license": "MIT" }, "node_modules/hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", - "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", "license": "MIT", "dependencies": { - "hermes-estree": "0.32.0" + "hermes-estree": "0.29.1" } }, "node_modules/hoist-non-react-statics": { @@ -6150,7 +6795,6 @@ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "react-is": "^16.7.0" } @@ -6159,8 +6803,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/hosted-git-info": { "version": "7.0.2", @@ -6180,6 +6823,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6209,6 +6859,20 @@ "node": ">= 0.8" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6222,6 +6886,21 @@ "node": ">= 14" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -6266,6 +6945,33 @@ "node": ">=16.x" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6298,6 +7004,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -6307,10 +7022,27 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, "license": "MIT" }, "node_modules/is-binary-path": { @@ -6325,6 +7057,18 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -6370,7 +7114,26 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-glob": { @@ -6385,6 +7148,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6403,6 +7182,39 @@ "node": ">=8" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -6446,15 +7258,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -6526,90 +7329,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-message-util/node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-message-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/jest-message-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-message-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-mock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", @@ -6650,37 +7369,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-util/node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -6696,45 +7384,6 @@ "node": ">=8" } }, - "node_modules/jest-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/jest-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -6749,77 +7398,7 @@ "pretty-format": "^29.7.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-validate/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/jest-validate/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-worker": { @@ -6837,15 +7416,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -6913,6 +7483,20 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6925,6 +7509,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6978,9 +7572,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6993,23 +7587,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -7027,9 +7621,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -7047,9 +7641,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -7067,9 +7661,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -7087,9 +7681,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -7107,12 +7701,15 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7127,12 +7724,15 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7147,12 +7747,15 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7167,12 +7770,15 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7187,9 +7793,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -7207,9 +7813,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -7245,15 +7851,17 @@ "license": "MIT" }, "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/lodash.debounce": { @@ -7280,6 +7888,77 @@ "node": ">=4" } }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7292,6 +7971,26 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7433,6 +8132,21 @@ "node": ">=20.19.4" } }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/metro-cache": { "version": "0.83.3", "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", @@ -7633,88 +8347,40 @@ "node": ">=20.19.4" } }, - "node_modules/metro/node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" }, - "node_modules/metro/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "hermes-estree": "0.32.0" } }, - "node_modules/metro/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/metro/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/metro/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" + "node": ">=8.3.0" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/metro/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/metro/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/metro/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" }, - "engines": { - "node": ">=8" + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/micromatch": { @@ -7772,13 +8438,23 @@ "node": ">=4" } }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -7797,12 +8473,13 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, "license": "ISC", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=8" } }, "node_modules/minizlib": { @@ -7817,6 +8494,15 @@ "node": ">= 18" } }, + "node_modules/minizlib/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -7865,14 +8551,14 @@ } }, "node_modules/nativewind": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/nativewind/-/nativewind-4.2.1.tgz", - "integrity": "sha512-10uUB2Dlli3MH3NDL5nMHqJHz1A3e/E6mzjTj6cl7hHECClJ7HpE6v+xZL+GXdbwQSnWE+UWMIMsNz7yOQkAJQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/nativewind/-/nativewind-4.2.2.tgz", + "integrity": "sha512-kUGbUamKUWdnAIjfBuhIrtDHFtMyL1pEE3AEbCuKeg656pHuB0KtJRk6Lrie/+8haj8hCSlwOleQFJLrE1sZgA==", "license": "MIT", "dependencies": { "comment-json": "^4.2.5", "debug": "^4.3.7", - "react-native-css-interop": "0.2.1" + "react-native-css-interop": "0.2.2" }, "engines": { "node": ">=16" @@ -7890,16 +8576,47 @@ "node": ">= 0.6" } }, - "node_modules/nested-error-stacks": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", - "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", - "license": "MIT" - }, + "node_modules/nested-error-stacks": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", + "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -7912,9 +8629,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, "node_modules/normalize-path": { @@ -7926,6 +8643,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-package-arg": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", @@ -7941,6 +8671,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -7989,6 +8731,51 @@ "node": ">= 6" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -8073,6 +8860,65 @@ "node": ">=6" } }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ora/node_modules/strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -8085,6 +8931,28 @@ "node": ">=6" } }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8101,21 +8969,23 @@ } }, "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "p-limit": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/p-locate/node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -8136,6 +9006,38 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-png": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", @@ -8157,13 +9059,21 @@ "node": ">= 0.8" } }, + "node_modules/path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/path-is-absolute": { @@ -8191,152 +9101,105 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, "engines": { - "node": ">=6" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-up/node_modules/p-limit": { + "node_modules/pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, "engines": { - "node": ">=6" + "node": ">= 6" } }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", "dev": true, "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", - "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", "license": "MIT", "dependencies": { - "@xmldom/xmldom": "^0.8.8", + "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" }, @@ -8344,6 +9207,15 @@ "node": ">=10.4.0" } }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/pngjs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", @@ -8353,6 +9225,15 @@ "node": ">=4.0.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -8424,9 +9305,9 @@ } }, "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "funding": [ { "type": "opencollective", @@ -8439,21 +9320,28 @@ ], "license": "MIT", "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" + "lilconfig": "^3.1.1" }, "engines": { - "node": ">= 14" + "node": ">= 18" }, "peerDependencies": { + "jiti": ">=1.21.0", "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { + "jiti": { + "optional": true + }, "postcss": { "optional": true }, - "ts-node": { + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } @@ -8540,6 +9428,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", @@ -8586,6 +9480,17 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8650,6 +9555,19 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8693,6 +9611,39 @@ "ws": "^7" } }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/react-freeze": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", @@ -8706,9 +9657,9 @@ } }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT" }, "node_modules/react-native": { @@ -8769,9 +9720,9 @@ } }, "node_modules/react-native-css-interop": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.1.tgz", - "integrity": "sha512-B88f5rIymJXmy1sNC/MhTkb3xxBej1KkuAt7TiT9iM7oXz3RM8Bn+7GUrfR02TvSgKm4cg2XiSuLEKYfKwNsjA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.2.tgz", + "integrity": "sha512-2eUyl7RH1RT6TYbe5nm+d4HZ2Pr6Nmve158B57tb5W4Bo52Xzp+PFeWAdFnAr2HNB+r9b6qa8o3xH1YREVQU0g==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.22.15", @@ -8926,6 +9877,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8946,6 +9900,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8966,6 +9923,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8986,6 +9946,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9039,12 +10002,23 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/react-native-css-interop/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native-gesture-handler": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz", - "integrity": "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", + "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", - "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -9056,9 +10030,9 @@ } }, "node_modules/react-native-is-edge-to-edge": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", - "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", "license": "MIT", "peerDependencies": { "react": "*", @@ -9076,19 +10050,30 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", - "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", + "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==", "license": "MIT", - "peer": true, "dependencies": { - "react-native-is-edge-to-edge": "1.2.1", - "semver": "7.7.3" + "react-native-is-edge-to-edge": "^1.2.1", + "semver": "^7.7.2" }, "peerDependencies": { "react": "*", - "react-native": "*", - "react-native-worklets": ">=0.7.0" + "react-native": "0.78 - 0.82", + "react-native-worklets": "0.5 - 0.8" + } + }, + "node_modules/react-native-reanimated/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/react-native-safe-area-context": { @@ -9131,120 +10116,124 @@ "react-native": "*" } }, - "node_modules/react-native-worklets": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz", - "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==", + "node_modules/react-native-svg-transformer": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-native-svg-transformer/-/react-native-svg-transformer-1.5.3.tgz", + "integrity": "sha512-M4uFg5pUt35OMgjD4rWWbwd6PmxV96W7r/gQTTa+iZA5B+jO6aURhzAZGLHSrg1Kb91cKG0Rildy9q1WJvYstg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/plugin-transform-arrow-functions": "7.27.1", - "@babel/plugin-transform-class-properties": "7.27.1", - "@babel/plugin-transform-classes": "7.28.4", - "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", - "@babel/plugin-transform-optional-chaining": "7.27.1", - "@babel/plugin-transform-shorthand-properties": "7.27.1", - "@babel/plugin-transform-template-literals": "7.27.1", - "@babel/plugin-transform-unicode-regex": "7.27.1", - "@babel/preset-typescript": "7.27.1", - "convert-source-map": "2.0.0", - "semver": "7.7.3" + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", + "@svgr/plugin-svgo": "^8.1.0", + "path-dirname": "^1.0.2" }, "peerDependencies": { - "@babel/core": "*", - "react": "*", - "react-native": "*" + "react-native": ">=0.59.0", + "react-native-svg": ">=12.0.0" } }, - "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "node_modules/react-native-url-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz", + "integrity": "sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "react-native": "*" } }, - "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-classes": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", - "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/react-native-worklets": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", + "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.0.0-0", + "@babel/plugin-transform-class-properties": "^7.0.0-0", + "@babel/plugin-transform-classes": "^7.0.0-0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", + "@babel/plugin-transform-optional-chaining": "^7.0.0-0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", + "@babel/plugin-transform-template-literals": "^7.0.0-0", + "@babel/plugin-transform-unicode-regex": "^7.0.0-0", + "@babel/preset-typescript": "^7.16.7", + "convert-source-map": "^2.0.0", + "semver": "7.7.2" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0-0", + "react": "*", + "react-native": "*" } }, - "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=10" } }, - "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", - "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "node_modules/react-native/node_modules/@react-native/virtualized-lists": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", + "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.27.1" + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" }, "engines": { - "node": ">=6.9.0" + "node": ">= 20.19.4" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@types/react": "^19.1.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/react-native/node_modules/brace-expansion": { @@ -9270,7 +10259,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -9288,9 +10277,9 @@ } }, "node_modules/react-native/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9299,6 +10288,27 @@ "node": "*" } }, + "node_modules/react-native/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native/node_modules/ws": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", @@ -9309,10 +10319,11 @@ } }, "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9386,9 +10397,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -9464,6 +10475,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -9473,18 +10491,6 @@ "node": ">=8" } }, - "node_modules/resolve-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", - "license": "MIT", - "dependencies": { - "global-dirs": "^0.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-workspace-root": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", @@ -9500,6 +10506,19 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", @@ -9540,9 +10559,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9553,7 +10572,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -9571,9 +10590,9 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9625,10 +10644,27 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -9641,15 +10677,12 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { @@ -9754,6 +10787,29 @@ "node": ">= 0.8" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9828,6 +10884,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -9844,14 +10906,25 @@ } }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "license": "MIT", "engines": { "node": ">=8.0.0" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -10011,6 +11084,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -10043,15 +11122,15 @@ } }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/supports-hyperlinks": { @@ -10067,43 +11146,76 @@ "node": ">=8" } }, - "node_modules/supports-hyperlinks/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svgo": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", + "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", + "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo" }, "engines": { - "node": ">=8" + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/svgo/node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/svgo/node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -10114,7 +11226,7 @@ "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.6", + "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", @@ -10123,7 +11235,7 @@ "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", @@ -10138,9 +11250,9 @@ } }, "node_modules/tar": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", - "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -10153,6 +11265,15 @@ "node": ">=18" } }, + "node_modules/tar/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -10162,15 +11283,6 @@ "node": ">=18" } }, - "node_modules/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -10188,9 +11300,9 @@ } }, "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", + "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -10239,7 +11351,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -10257,9 +11369,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -10367,12 +11479,24 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -10395,7 +11519,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10405,19 +11529,45 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -10460,18 +11610,6 @@ "node": ">=4" } }, - "node_modules/unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -10529,6 +11667,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10602,13 +11753,10 @@ } }, "node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=8" - } + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" }, "node_modules/whatwg-fetch": { "version": "3.6.20", @@ -10616,6 +11764,16 @@ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -10630,6 +11788,15 @@ "node": ">=10" } }, + "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10645,10 +11812,31 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wonka": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", - "integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", "license": "MIT" }, "node_modules/wrap-ansi": { @@ -10668,39 +11856,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10721,16 +11876,16 @@ } }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -10855,9 +12010,9 @@ } }, "node_modules/zustand": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", - "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index 5370f1b..013e368 100644 --- a/package.json +++ b/package.json @@ -4,35 +4,53 @@ "main": "index.ts", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web" }, "dependencies": { + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.10.1", "@react-navigation/native": "^7.1.28", "@react-navigation/native-stack": "^7.10.1", "@react-navigation/stack": "^7.6.16", + "@supabase/supabase-js": "^2.98.0", "@tanstack/react-query": "^5.90.19", "axios": "^1.13.2", - "babel-preset-expo": "~54.0.9", - "expo": "~54.0.31", + "babel-preset-expo": "~54.0.10", + "expo": "~54.0.33", + "expo-auth-session": "~7.0.10", + "expo-crypto": "~15.0.8", + "expo-device": "~8.0.10", + "expo-font": "~14.0.11", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.16", + "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.10", "nativewind": "^4.2.1", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", "react-native-pager-view": "6.9.1", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", + "react-native-url-polyfill": "^3.0.0", + "react-native-web": "^0.21.0", + "react-native-worklets": "0.5.1", "tailwindcss": "^3.4.17", "zustand": "^5.0.10" }, "devDependencies": { + "@expo/ngrok": "^4.1.3", "@types/react": "~19.1.0", "babel-plugin-module-resolver": "^5.0.2", + "react-native-svg-transformer": "^1.5.3", "typescript": "~5.9.2" }, "private": true diff --git a/sometips.md b/sometips.md new file mode 100644 index 0000000..ee1a36e --- /dev/null +++ b/sometips.md @@ -0,0 +1,290 @@ +# Claude Code 스킬 완전 가이드 + +> 출처: unclejobs.ai — AI Threads + +--- + +## 목차 + +1. [스킬은 마크다운 파일이 아니다](#스킬은-마크다운-파일이-아니다) +2. [9가지 스킬 유형](#9가지-스킬-유형) + - [1. 라이브러리/API 레퍼런스](#1-라이브러리api-레퍼런스) + - [2. 제품 검증](#2-제품-검증-product-verification) + - [3. 데이터 패칭/분석](#3-데이터-패칭분석) + - [4. 비즈니스 프로세스/팀 자동화](#4-비즈니스-프로세스팀-자동화) + - [5. 코드 스캐폴딩/템플릿](#5-코드-스캐폴딩템플릿) + - [6. 코드 품질/리뷰](#6-코드-품질리뷰) + - [7. CI/CD/배포](#7-cicd배포) + - [8. 런북(Runbook)](#8-런북runbook) + - [9. 인프라 운영](#9-인프라-운영) +3. [잘 만드는 법 — Anthropic의 8가지 팁](#잘-만드는-법--anthropic의-8가지-팁) +4. [온디맨드 훅](#온디맨드-훅--필요할-때만-켜지는-안전장치) +5. [팀에 퍼뜨리는 방법](#팀에-어떻게-퍼뜨리나) +6. [스킬 사용량 측정](#스킬-사용량-측정) + +--- + +## 스킬은 마크다운 파일이 아니다 + +가장 먼저 깨야 할 오해가 있어요. + +> "스킬은 그냥 .md 파일 아닌가요?" + +아닙니다. 스킬은 **폴더**예요. + +마크다운 파일 하나가 아니라, 그 안에 스크립트, 예제 코드, 데이터 파일, 템플릿을 전부 담을 수 있는 폴더 구조입니다. Claude에게 "이 폴더 안에 이런 파일들이 있어"라고 알려주면, 필요한 시점에 알아서 꺼내 읽어요. + +SKILL.md 하나에 모든 걸 때려넣는 게 아니라, 폴더 전체를 하나의 지식 체계로 설계하는 거예요. 이게 Anthropic이 "가장 흥미로운 스킬들"이라고 부르는 것들의 공통점이래요. 설정 옵션과 폴더 구조를 창의적으로 쓰고 있다는 거. + +--- + +## 9가지 스킬 유형 + +좋은 스킬은 하나에 딱 속하고, 헷갈리는 스킬은 여러 개에 걸쳐 있었다고. 이건 "우리 팀에 어떤 유형이 빠졌는지" 체크하는 프레임워크로 쓰면 좋아요. + +### 1. 라이브러리/API 레퍼런스 + +"이 라이브러리는 이렇게 써야 해"를 가르치는 스킬. + +회사에 내부 빌링 라이브러리가 있다고 해봐요. 특정 메서드를 호출할 때 순서를 잘못하면 에러가 나는 함정이 있는데, Claude는 당연히 이걸 모릅니다. 이런 엣지 케이스와 "이렇게 하면 안 돼" 목록을 스킬에 담는 거예요. 레퍼런스 코드 스니펫 폴더도 같이. + +외부 라이브러리도 마찬가지. Claude가 자주 실수하는 부분이 있으면 스킬로 교정할 수 있어요. + +**예시:** +- **billing-lib** — 사내 빌링 라이브러리의 엣지 케이스, 함정 모음 +- **internal-platform-cli** — 사내 CLI의 모든 서브커맨드와 사용 시점 예시 +- **frontend-design** — Claude가 우리 디자인 시스템을 더 잘 다루게 만드는 스킬 + +--- + +### 2. 제품 검증 (Product Verification) + +"코드가 진짜 돌아가는지 테스트하는 법"을 가르치는 스킬. + +Claude가 코드를 짜주긴 하는데, 그게 실제로 맞게 작동하는지는 어떻게 확인하죠? 이 스킬은 Playwright(브라우저 자동화 도구)나 tmux 같은 외부 도구와 짝을 이뤄서, Claude가 직접 테스트를 돌리고 결과를 검증하게 만들어요. + +Anthropic이 이렇게까지 말합니다. + +> "엔지니어 한 명이 일주일을 투자해서 검증 스킬 하나를 완벽하게 만드는 것도 충분히 가치가 있다." + +Claude가 테스트 과정을 영상으로 녹화하게 해서 정확히 뭘 테스트했는지 눈으로 볼 수 있게 만들거나, 각 단계에서 프로그래밍적 검증(assertion)을 강제하는 방법도 추천해요. + +**예시:** +- **signup-flow-driver** — 회원가입 → 이메일 인증 → 온보딩을 헤드리스 브라우저에서 실행, 각 단계마다 상태 검증 +- **checkout-verifier** — Stripe 테스트 카드로 결제 UI를 구동하고 인보이스가 맞는 상태에 도달했는지 확인 +- **tmux-cli-driver** — TTY가 필요한 대화형 CLI 테스트용 + +--- + +### 3. 데이터 패칭/분석 + +"데이터를 어디서 어떻게 가져오는지"를 가르치는 스킬. + +회사의 데이터 스택, 모니터링 도구에 연결하는 거예요. 접속 정보, 대시보드 ID, 일반적인 분석 워크플로우를 스킬에 담습니다. + +"가입부터 결제까지 퍼널을 보려면 어떤 테이블의 어떤 이벤트를 조인해야 하는지" + 실제 user_id가 들어있는 캐노니컬 테이블이 뭔지. 이런 걸 스킬에 넣어두면 Claude가 매번 "그 테이블 이름이 뭐였죠?"라고 물을 필요가 없어요. + +**예시:** +- **funnel-query** — 가입 → 활성화 → 결제 퍼널의 이벤트 조인 방법 + 캐노니컬 user_id 테이블 +- **cohort-compare** — 두 코호트의 리텐션/전환율 비교, 통계적 유의성 표시 +- **grafana** — 데이터소스 UID, 클러스터 이름, "이 문제면 이 대시보드" 매핑 + +--- + +### 4. 비즈니스 프로세스/팀 자동화 + +매일 반복하는 업무를 한 커맨드로 자동화하는 스킬. + +스탠드업 포스트 스킬을 예로 들어볼게요. 티켓 트래커에서 내 할당 업무를 가져오고, GitHub에서 어제 커밋 내역을 모으고, Slack에서 관련 대화를 긁어서 포맷에 맞게 정리합니다. 이전 스탠드업과 비교해서 "어제 대비 뭐가 바뀌었는지"만 보여주는 delta-only 방식. + +팁이 하나 있어요. 이런 스킬은 이전 실행 결과를 로그 파일에 저장해두면, Claude가 "지난번에 뭘 했는지"를 읽고 일관성을 유지할 수 있대요. + +**예시:** +- **standup-post** — 티켓 + GitHub + Slack 집계 → 포맷된 스탠드업(delta-only) +- **create-ticket** — 스키마 강제(유효한 값, 필수 필드) + 생성 후 워크플로우(리뷰어 핑, Slack 링크) +- **weekly-recap** — 머지된 PR + 닫힌 티켓 + 배포 → 주간 요약 + +--- + +### 5. 코드 스캐폴딩/템플릿 + +새 기능을 만들 때 뼈대를 자동 생성하는 스킬. + +"새 API 엔드포인트 만들어줘"라고 하면, 우리 조직의 인증 방식, 로깅 설정, 배포 구성이 미리 연결된 상태로 파일 구조를 잡아주는 거예요. 순수 코드 제너레이터로는 커버 안 되는 자연어 요구사항("이 서비스는 이런 컨벤션을 따라야 해")이 있을 때 특히 유용합니다. + +**예시:** +- **new-workflow** — 조직의 어노테이션이 포함된 새 서비스/핸들러 스캐폴딩 +- **new-migration** — 마이그레이션 파일 템플릿 + 일반적인 함정 +- **create-app** — 인증, 로깅, 배포 설정이 미리 연결된 새 내부 앱 + +--- + +### 6. 코드 품질/리뷰 + +조직의 코드 품질 기준을 강제하는 스킬. + +재밌는 예가 있어요. `adversarial-review` 스킬. 서브에이전트를 하나 더 띄워서 "처음 보는 사람의 눈"으로 코드를 비판하게 합니다. 지적 사항을 수정하고, 다시 리뷰하고, 지적이 사소한 수준(nitpick)까지 내려갈 때까지 반복. + +훅이나 GitHub Action에 연결해서 자동 실행되게 할 수도 있어요. + +**예시:** +- **adversarial-review** — 서브에이전트가 비판 → 수정 → 반복, 지적이 사소해질 때까지 +- **code-style** — Claude가 기본적으로 잘 못하는 코드 스타일 강제 +- **testing-practices** — 테스트 작성법과 무엇을 테스트해야 하는지 안내 + +--- + +### 7. CI/CD/배포 + +코드를 빌드하고 테스트하고 배포하는 과정을 자동화하는 스킬. + +`babysit-pr` 스킬이 대표적이에요. PR을 올리면 CI가 돌아가잖아요. 가끔 환경 문제로 무작위 실패하는 불안정한 테스트가 있는데, 이 스킬이 알아서 재시도하고, 머지 충돌이 생기면 해결하고, 문제없으면 오토 머지까지 켜줍니다. + +배포 스킬은 더 정교해요. 빌드 → 스모크 테스트 → 트래픽 점진적 롤아웃 → 에러율 비교 → 리그레션 시 자동 롤백. 사람이 붙어서 모니터링하던 걸 스킬이 대신합니다. + +**예시:** +- **babysit-pr** — PR 모니터링 → 불안정 CI 재시도 → 머지 충돌 해결 → 오토 머지 +- **deploy-service** — 빌드 → 스모크 테스트 → 점진적 트래픽 → 에러 시 자동 롤백 +- **cherry-pick-prod** — 격리된 워크트리 → 체리픽 → 충돌 해결 → 템플릿 PR + +--- + +### 8. 런북(Runbook) + +"이런 증상이 나타나면 이렇게 조사해라"를 자동화한 스킬. + +장애가 나면 보통 이래요. Slack에서 알림 → 로그 확인 → 관련 시스템 확인 → 원인 파악 → 보고서. 런북 스킬은 이 전체를 자동화합니다. + +요청 ID 하나를 주면 그 요청이 지나간 모든 시스템에서 매칭 로그를 뽑아서 하나로 모아주는 `log-correlator` 같은 거예요. + +**예시:** +- **service-debugging** — 고트래픽 서비스의 증상 → 도구 → 쿼리 패턴 매핑 +- **oncall-runner** — 알림 → 일반적 원인 확인 → 발견 사항 포맷팅 +- **log-correlator** — 요청 ID → 모든 시스템에서 매칭 로그 추출 + +--- + +### 9. 인프라 운영 + +정기 유지보수와 운영 절차를 자동화하는 스킬. 특히 "실수하면 큰일 나는" 파괴적 작업에 가드레일을 다는 용도예요. + +고아 상태(아무도 안 쓰는) 쿠버네티스 파드나 볼륨을 찾아서 → Slack에 알리고 → 며칠 기다리고(혹시 누가 쓰는지) → 사용자가 확인하면 → 정리. 자동이되 사람의 확인을 거치는 구조. + +**예시:** +- **resource-orphans** — 고아 파드/볼륨 탐색 → Slack 알림 → 대기 → 확인 → 정리 +- **dependency-management** — 조직의 의존성 승인 워크플로우 +- **cost-investigation** — "왜 스토리지 비용이 급증했나" + 특정 버킷과 쿼리 패턴 + +--- + +## 잘 만드는 법 — Anthropic의 8가지 팁 + +### 팁 1. 당연한 걸 적지 마라 + +Claude는 코딩에 대해 이미 많이 알아요. "변수명은 의미 있게 지어라" 같은 일반 상식을 스킬에 적으면 토큰만 낭비됩니다. + +스킬에는 **Claude가 평소에 모르거나 틀리는 것만** 적어야 해요. + +`frontend-design` 스킬이 좋은 예예요. Anthropic의 한 엔지니어가 고객 피드백을 반복 반영하면서 만들었는데, Claude가 기본적으로 쓰는 Inter 폰트, 보라색 그라데이션 같은 "AI 티 나는 패턴"을 피하게 한 거예요. "일반적인 좋은 디자인"이 아니라 "Claude가 구체적으로 틀리는 부분"에 집중한 겁니다. + +### 팁 2. Gotchas 섹션이 가장 중요하다 + +모든 스킬에서 가장 가치 있는 부분이 뭐냐고 물었더니 Anthropic의 답은 **Gotchas 섹션**이래요. + +Gotchas는 "Claude가 이 스킬을 쓸 때 자주 빠지는 함정" 목록이에요. 처음에는 하나둘로 시작하지만, Claude가 새 엣지 케이스를 만날 때마다 추가합니다. + +이게 스킬의 진짜 성장 방식이에요. 완벽하게 만들어서 배포하는 게 아니라, **실패할 때마다 Gotchas에 한 줄 추가**하는 것. + +### 팁 3. 파일 시스템을 활용해라 — 점진적 공개 + +스킬은 폴더라고 했죠. 이걸 제대로 쓰면 "**점진적 공개(Progressive Disclosure)**"가 가능해요. + +모든 정보를 한꺼번에 보여주지 않고, 필요할 때만 꺼내 보는 거예요. + +- `SKILL.md` — 핵심 지침만 +- `references/api.md` — 상세한 API 사용법 +- `assets/` — 출력 템플릿 +- `examples/` — 예제 코드 + +Claude에게 "이 파일들이 있으니 필요하면 읽어"라고 알려주면 적절한 타이밍에 가져옵니다. 하나의 마크다운에 전부 때려넣으면 매번 모든 걸 읽어야 하니까 토큰이 낭비돼요. + +### 팁 4. Claude를 너무 가두지 마라 + +스킬은 재사용되는 거잖아요. 지시사항이 너무 구체적이면 상황이 조금만 달라져도 안 맞습니다. + +- **나쁜 예:** "반드시 A를 호출하고, 그 다음 B를 호출하고, 결과를 C 형식으로 저장하라" +- **좋은 예:** "이 함수들이 있고, 각각의 용도는 이렇다. 상황에 맞게 조합해서 써라" + +필요한 정보를 주되, 판단의 유연성을 남겨두라는 거예요. + +### 팁 5. 셋업을 설계해라 + +어떤 스킬은 처음에 사용자한테 물어봐야 할 게 있어요. "스탠드업을 어느 Slack 채널에 올릴까요?" 같은 거. + +좋은 패턴은 스킬 폴더 안에 `config.json`을 두는 거예요. 설정 파일이 비어있으면 Claude가 사용자에게 질문합니다. 답을 받아서 저장하면 다음부터는 묻지 않아요. + +구조화된 객관식 질문을 제시하고 싶으면 Claude에게 `AskUserQuestion` 도구를 쓰라고 지시하면 됩니다. + +### 팁 6. description은 "언제 쓸지"를 적는 곳이다 + +세션이 시작되면 Claude Code는 설치된 모든 스킬의 `description`을 스캔해서 "이 요청에 맞는 스킬이 있나?"를 판단해요. + +그러니까 description은 요약이 아닙니다. **"이 스킬을 언제 호출해야 하는지"를 적는 트리거 조건**이에요. + +- **나쁜 예:** "코드 리뷰를 도와주는 스킬입니다" +- **좋은 예:** "PR이 올라왔을 때 보안 취약점, 누락된 테스트, 동작 리그레션을 검토합니다. 사용자가 '리뷰해줘', 'PR 체크'라고 말할 때 호출." + +이 차이가 스킬이 제때 불리느냐 마느냐를 결정합니다. + +### 팁 7. 스킬에 기억을 넣어라 + +스킬 안에 데이터를 저장할 수 있어요. 단순한 텍스트 로그, JSON, 심지어 SQLite까지. + +`standup-post` 스킬이 매일 `standups.log`에 기록을 남기면, 다음에 실행할 때 Claude가 이전 기록을 읽고 "어제 대비 뭐가 바뀌었는지"를 알 수 있어요. + +> **주의:** 스킬 업그레이드 시 디렉토리가 삭제될 수 있어요. `${CLAUDE_PLUGIN_DATA}` 같은 안정적인 경로에 저장하라고 합니다. + +### 팁 8. 스크립트를 넣어라 — 코드가 가장 강력한 도구다 + +데이터 분석 스킬에 데이터 페칭 함수 라이브러리를 넣어두면, Claude는 매번 데이터를 가져오는 코드를 처음부터 쓰는 대신, 이 함수들을 조합해서 분석하는 데 집중할 수 있어요. + +"화요일에 무슨 일이 있었어?"라고 물으면 Claude가 미리 준비된 함수를 조합해서 스크립트를 즉석으로 짜고 실행합니다. 보일러플레이트에 턴을 쓰지 않으니까 토큰이 줄어요. + +--- + +## 온디맨드 훅 — 필요할 때만 켜지는 안전장치 + +스킬이 호출될 때만 활성화되고 세션 동안만 유지되는 훅을 넣을 수 있어요. 항상 켜두면 짜증나지만, 특정 상황에서는 꼭 필요한 것들. + +- **/careful** — `rm -rf`, `DROP TABLE`, `force-push`, `kubectl delete`를 전부 차단. 프로덕션 작업할 때만 켜는 거예요. 항상 켜두면 일상 작업이 불가능해집니다. +- **/freeze** — 특정 디렉토리 이외의 파일 수정을 전부 차단. 디버깅할 때 "로그만 추가하려고 했는데 실수로 다른 파일까지 고쳐버렸다"를 방지. + +--- + +## 팀에 어떻게 퍼뜨리나 + +두 가지 방법이 있어요. + +**레포에 체크인** +`.claude/skills/` 폴더에 넣어서 Git으로 관리. 팀 전원이 같은 스킬을 씁니다. 작은 팀에 적합. 단점은 스킬이 많아지면 전부 Claude 컨텍스트에 올라간다는 것. + +**사내 플러그인 마켓플레이스** +규모가 커지면 이쪽. 팀원이 필요한 스킬만 골라서 설치하는 구조. + +Anthropic의 마켓플레이스 운영 방식이 재밌어요. 중앙 팀이 결정하지 않습니다. + +1. 스킬을 만들면 GitHub 샌드박스 폴더에 올림 +2. Slack에서 "이거 써봐" 하고 공유 +3. 견인력이 생기면(스킬 오너가 판단) 마켓플레이스에 PR + +유기적으로 떠오르게 하는 거예요. 경고도 하나 남겼습니다. + +> "나쁘거나 중복된 스킬을 만드는 건 쉽다. 릴리스 전에 큐레이션 과정이 반드시 필요하다." + +--- + +## 스킬 사용량 측정 + +Anthropic은 `PreToolUse` 훅으로 사내 스킬 사용량을 로깅하고 있대요. 어떤 스킬이 많이 쓰이는지, 기대보다 호출이 적은 건 뭔지 데이터로 파악합니다. + +기대보다 안 쓰이면 `description`(트리거 조건)을 다시 쓰고, 인기 있는 스킬은 더 다듬고. diff --git a/src/apis/avatars/avatarApi.ts b/src/apis/avatars/avatarApi.ts new file mode 100644 index 0000000..e37da50 --- /dev/null +++ b/src/apis/avatars/avatarApi.ts @@ -0,0 +1,30 @@ +import api from "@/apis/instance"; +import type { + FinalChoiceAvatarRequest, + FinalChoiceAvatarResponse, + SelectAvatarResponse, + UploadCreationAvatarResponse, +} from "@/types/avatars"; + +export const getAvatarMastersApi = async (): Promise => { + const res = await api.get("/api/v1/avatars/masters"); + return res.data; +}; + +export const postUploadCreationAvatarApi = async ( + formData: FormData +): Promise => { + const res = await api.post("/api/v1/register/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return res.data; +}; + +export const postFinalChoiceAvatarApi = async ( + payload: FinalChoiceAvatarRequest +): Promise => { + const res = await api.post("/api/v1/avatars", payload); + return res.data; +}; diff --git a/src/apis/block/blockApi.ts b/src/apis/block/blockApi.ts new file mode 100644 index 0000000..102a729 --- /dev/null +++ b/src/apis/block/blockApi.ts @@ -0,0 +1,22 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; + +type BlockUserPayload = { + userIdToBlock: number; +}; + +export const postBlockUser = async ( + userIdToBlock: number +): ApiResponse => { + const res = await api.post("/api/v1/blocks", { userIdToBlock } satisfies BlockUserPayload); + return res.data; +}; + +export const deleteBlockUser = async ( + userIdToBlock: number +): ApiResponse => { + const res = await api.delete("/api/v1/blocks", { + data: { userIdToBlock } satisfies BlockUserPayload, + }); + return res.data; +}; diff --git a/src/apis/comments/commentApi.ts b/src/apis/comments/commentApi.ts index b47bbd8..21a1aff 100644 --- a/src/apis/comments/commentApi.ts +++ b/src/apis/comments/commentApi.ts @@ -11,3 +11,8 @@ export const postComment = async ( const res = await api.post("/api/v1/comments", body); return res.data; }; + +export const deleteComment = async (commentId: number): ApiResponse => { + const res = await api.delete(`/api/v1/comments/${commentId}`); + return res.data; +}; diff --git a/src/apis/delivery/deliveryApi.ts b/src/apis/delivery/deliveryApi.ts new file mode 100644 index 0000000..7cb92c1 --- /dev/null +++ b/src/apis/delivery/deliveryApi.ts @@ -0,0 +1,21 @@ +import api from "@/apis/instance"; +import type { GlobalResponse } from "@/types/common/apiResponse.type"; +import type { CreateSeedDeliveryRequest, DeliverablePlant } from "@/types/delivery"; + +export const getDeliverablePlants = async (): Promise< + GlobalResponse +> => { + const res = await api.get("/api/v1/deliveries/plants"); + return res.data; +}; + +export const postSeedDelivery = async ( + payload: CreateSeedDeliveryRequest +): Promise> => { + const res = await api.post("/api/v1/deliveries/seeds", payload); + return res.data; +}; + +export const postUnlockGarden = async (): Promise => { + await api.post("/api/v1/gardens/unlock"); +}; diff --git a/src/apis/feed/likeApi.ts b/src/apis/feed/likeApi.ts new file mode 100644 index 0000000..eef3436 --- /dev/null +++ b/src/apis/feed/likeApi.ts @@ -0,0 +1,30 @@ +import api from "@/apis/instance"; +import type { ApiResponse, NoResponse } from "@/types/common/apiResponse.type"; + +export type FeedLikeTargetType = "DIARY" | "AVATAR_POST"; + +export const likeFeedTarget = async ( + targetId: number, + targetType: FeedLikeTargetType +): ApiResponse => { + const endpoint = + targetType === "DIARY" + ? `/api/v1/diaries/${targetId}/likes` + : `/api/v1/avatar-posts/${targetId}/likes`; + + const res = await api.post(endpoint); + return res.data; +}; + +export const unlikeFeedTarget = async ( + targetId: number, + targetType: FeedLikeTargetType +): ApiResponse => { + const endpoint = + targetType === "DIARY" + ? `/api/v1/diaries/${targetId}/likes` + : `/api/v1/avatar-posts/${targetId}/likes`; + + const res = await api.delete(endpoint); + return res.data; +}; diff --git a/src/apis/feed/randomFeedApi.ts b/src/apis/feed/randomFeedApi.ts new file mode 100644 index 0000000..4b5eeee --- /dev/null +++ b/src/apis/feed/randomFeedApi.ts @@ -0,0 +1,21 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; +import type { + RandomFeedSessionNextRequest, + RandomFeedSessionPayload, + RandomFeedSessionStartRequest, +} from "@/types/feed/randomFeedApi.type"; + +export const startRandomFeedSession = async ( + payload: RandomFeedSessionStartRequest +): ApiResponse => { + const res = await api.post("/api/v1/feed/random/session", payload); + return res.data; +}; + +export const getRandomFeedSessionNext = async ( + payload: RandomFeedSessionNextRequest +): ApiResponse => { + const res = await api.post("/api/v1/feed/random/next", payload); + return res.data; +}; diff --git a/src/apis/follow/followApi.ts b/src/apis/follow/followApi.ts new file mode 100644 index 0000000..f366e3b --- /dev/null +++ b/src/apis/follow/followApi.ts @@ -0,0 +1,32 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; +import type { FollowResponse } from "@/types/follow"; +import type { FollowUserResponse } from "@/types/profile/profileApi.type"; + +export const getFollowing = async ( + userId: string | number +): ApiResponse => { + const res = await api.get(`/api/v1/users/${userId}/following`); + return res.data; +}; + +export const getFollowers = async ( + userId: string | number +): ApiResponse => { + const res = await api.get(`/api/v1/users/${userId}/followers`); + return res.data; +}; + +export const postFollowUser = async ( + userId: string | number +): ApiResponse => { + const res = await api.post(`/api/v1/users/${userId}/follow`); + return res.data; +}; + +export const deleteFollowUser = async ( + userId: string | number +): ApiResponse => { + const res = await api.delete(`/api/v1/users/${userId}/follow`); + return res.data; +}; diff --git a/src/apis/home/homeApi.ts b/src/apis/home/homeApi.ts new file mode 100644 index 0000000..a986788 --- /dev/null +++ b/src/apis/home/homeApi.ts @@ -0,0 +1,75 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; +import { + normalizeHomeSummaryPayload, + type HomeSummaryPayload, +} from "@/types/home/garden"; +import type { GuestbookEntry, NotificationItem } from "@/types/home/alerts"; +import type { HomePanelPayload } from "@/types/home/panel"; +import type { + TrackingPromptConfirmRequest, + TrackingPromptStatusPayload, +} from "@/types/home/tracking"; + +export const getHomeSummary = async (): ApiResponse => { + const res = await api.get("/api/v1/home"); + + return { + ...res.data, + result: normalizeHomeSummaryPayload(res.data.result), + }; +}; + +export const getHomePanel = async (): ApiResponse => { + const res = await api.get("/api/v1/home/panel"); + return res.data; +}; + +export const getTrackingPromptStatus = + async (): ApiResponse => { + // 한글 주석: + // 홈 자동 팝업 여부는 앱이 계산하지 않고 서버의 eligible 판정만 그대로 조회한다. + const res = await api.get("/api/v1/tracking/report/status"); + return res.data; + }; + +export const postTrackingPromptConfirm = async ( + payload: TrackingPromptConfirmRequest +) => { + // 한글 주석: + // 사용자가 이번 주기 리포트를 확인했다는 사실을 서버에 저장해 같은 cycle 재노출을 막는다. + const res = await api.post("/api/v1/tracking/report/confirm", payload); + return res.data; +}; + +export const getNotifications = async (): ApiResponse => { + const res = await api.get("/api/v1/notifications"); + return res.data; +}; + +export const patchNotificationRead = async (notificationId: number) => { + const res = await api.patch(`/api/v1/notifications/${notificationId}/read`); + return res.data; +}; + +export const patchAllNotificationsRead = async () => { + const res = await api.patch("/api/v1/notifications/read-all"); + return res.data; +}; + +export const getGuestbookList = async ( + userId: number +): ApiResponse => { + const res = await api.get(`/api/v1/users/guestbook/${userId}/list`); + return res.data; +}; + +export const postGardenSunlight = async (gardenId: number) => { + const res = await api.post(`/api/v1/gardens/${gardenId}/sunlight`); + return res.data; +}; + +export const postGardenMyWater = async (gardenId: number) => { + const res = await api.post(`/api/v1/gardens/${gardenId}/mywater`); + return res.data; +}; diff --git a/src/apis/instance.ts b/src/apis/instance.ts index 21cb983..4e90dbf 100644 --- a/src/apis/instance.ts +++ b/src/apis/instance.ts @@ -1,10 +1,16 @@ import axios, { + AxiosHeaders, AxiosError, + AxiosRequestHeaders, InternalAxiosRequestConfig, } from "axios"; +import { refreshAuthApi } from "@/apis/register/refreshApi"; +import useTokenStore from "@/stores/useTokenStore"; +import { logout } from "@/utils/auth"; type ReqConfig = InternalAxiosRequestConfig & { _skipAuth?: boolean; + _retry?: boolean; }; // 환경변수는 app.config.ts 또는 .env에서 설정 @@ -14,37 +20,92 @@ const api = axios.create({ baseURL: API_URL, }); +let refreshPromise: Promise | null = null; + +const refreshAccessToken = async (): Promise => { + if (!refreshPromise) { + refreshPromise = (async () => { + const { refreshToken, userId } = useTokenStore.getState(); + + if (!refreshToken) { + return null; + } + + try { + const response = await refreshAuthApi(refreshToken); + + if (!response.isSuccess || !response.result?.accessToken) { + return null; + } + + useTokenStore.getState().setAuth({ + accessToken: response.result.accessToken, + refreshToken: response.result.refreshToken || refreshToken, + userId: response.result.userId + ? String(response.result.userId) + : userId, + }); + + return response.result.accessToken; + } catch (refreshError) { + return null; + } finally { + refreshPromise = null; + } + })(); + } + + return refreshPromise; +}; + /** 요청 인터셉터: accessToken 부착 (옵션으로 스킵 가능) */ api.interceptors.request.use((config: ReqConfig) => { if (config._skipAuth) return config; - // TODO: 토큰 스토어 마이그레이션 후 활성화 - // const { accessToken } = useTokenStore.getState(); - // const headers = (config.headers ??= - // new AxiosHeaders()) as AxiosRequestHeaders; - // if (accessToken) headers.Authorization = `Bearer ${accessToken}`; - // else delete headers.Authorization; + const { accessToken } = useTokenStore.getState(); + const headers = (config.headers ??= + new AxiosHeaders()) as AxiosRequestHeaders; + + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } else { + delete headers.Authorization; + } return config; }); -/** 응답 인터셉터: 403 → onboarding 이동, 401 → 토큰 클리어 */ +/** 응답 인터셉터: 401 refresh 1회 재시도, 실패 시 인증 해제 */ api.interceptors.response.use( res => res, async (error: AxiosError) => { const status = error.response?.status; + const originalRequest = error.config as ReqConfig | undefined; if (status === 403) { - // React Navigation으로 온보딩 화면으로 이동 - // router.replace("/onboarding"); return Promise.reject(error); } - if (status === 401) { - // TODO: 토큰 스토어 마이그레이션 후 활성화 - // const store = useTokenStore.getState(); - // store.clearTokens?.(); - return Promise.reject(error); + if ( + status === 401 && + originalRequest && + !originalRequest._skipAuth && + !originalRequest._retry + ) { + originalRequest._retry = true; + + const nextAccessToken = await refreshAccessToken(); + + if (!nextAccessToken) { + await logout(); + return Promise.reject(error); + } + + const headers = (originalRequest.headers ??= + new AxiosHeaders()) as AxiosRequestHeaders; + headers.Authorization = `Bearer ${nextAccessToken}`; + + return api(originalRequest); } return Promise.reject(error); diff --git a/src/apis/missions/missionApi.ts b/src/apis/missions/missionApi.ts new file mode 100644 index 0000000..b5f926c --- /dev/null +++ b/src/apis/missions/missionApi.ts @@ -0,0 +1,67 @@ +import api from "@/apis/instance"; +import type { + AnswerDailySurveyRequest, + AnswerDailySurveyResponse, + AnswerQuizRequest, + AnswerQuizResponse, + DiaryImageUploadResponse, + GetDailySurveyResponse, + GetQuizRequest, + GetQuizResponse, + GetTodayKeywordResponse, + WriteDiaryRequest, + WriteDiaryResponse, +} from "@/types/missions"; + +export const uploadDiaryImageApi = async ( + formData: FormData +): Promise => { + const res = await api.post("/api/v1/diaries/images", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + return res.data; +}; + +export const writeDiaryApi = async ( + payload: WriteDiaryRequest +): Promise => { + const res = await api.post("/api/v1/diaries", payload); + return res.data; +}; + +export const getTodayKeywordApi = async (): Promise => { + const res = await api.get("/api/v1/keywords/today"); + return res.data; +}; + +export const getQuizApi = async ( + params: GetQuizRequest +): Promise => { + const res = await api.get("/api/v1/realQuiz", { params }); + return res.data; +}; + +export const answerQuizApi = async ({ + quizId, + selectedOptionOrder, +}: AnswerQuizRequest): Promise => { + const res = await api.post(`/api/v1/realQuiz/${quizId}/answer`, { + selectedOptionOrder, + }); + return res.data; +}; + +export const getDailySurveyApi = async (): Promise => { + const res = await api.get("/api/v1/survey"); + return res.data; +}; + +export const answerDailySurveyApi = async ( + payload: AnswerDailySurveyRequest +): Promise => { + const res = await api.post("/api/v1/survey/answer", payload); + return res.data; +}; diff --git a/src/apis/option/avatarApi.ts b/src/apis/option/avatarApi.ts new file mode 100644 index 0000000..7889c51 --- /dev/null +++ b/src/apis/option/avatarApi.ts @@ -0,0 +1,16 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; + +export type UpdateAvatarPayload = { + avatarId: number; + newAvatarName: string; +}; + +export const updateAvatarNickname = async ( + payload: UpdateAvatarPayload +): ApiResponse => { + const res = await api.patch(`/api/v1/users/me/${payload.avatarId}`, { + newAvatarName: payload.newAvatarName, + }); + return res.data; +}; diff --git a/src/apis/option/notificationApi.ts b/src/apis/option/notificationApi.ts new file mode 100644 index 0000000..b758792 --- /dev/null +++ b/src/apis/option/notificationApi.ts @@ -0,0 +1,29 @@ +import api from "@/apis/instance"; +import type { ApiResponse, NoResponse } from "@/types/common/apiResponse.type"; + +export type NotificationSettings = { + notificationEnabled: boolean; + marketingConsent: boolean; +}; + +export const getNotificationSettings = async (): ApiResponse => { + const res = await api.get("/api/v1/notifications/settings"); + return res.data; +}; + +export const patchNotificationSettings = async ( + settings: Partial +): ApiResponse => { + const res = await api.patch("/api/v1/notifications/settings", settings); + return res.data; +}; + +export const postFcmToken = async (token: string): ApiResponse => { + const res = await api.post("/api/v1/notifications/token", { token }); + return res.data; +}; + +export const deleteFcmToken = async (): ApiResponse => { + const res = await api.delete("/api/v1/notifications/token"); + return res.data; +}; diff --git a/src/apis/option/policyApi.ts b/src/apis/option/policyApi.ts new file mode 100644 index 0000000..66859fa --- /dev/null +++ b/src/apis/option/policyApi.ts @@ -0,0 +1,7 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; + +export const getPolicy = async (): ApiResponse => { + const res = await api.get("/api/v1/policy"); + return res.data; +}; diff --git a/src/apis/option/userApi.ts b/src/apis/option/userApi.ts new file mode 100644 index 0000000..5194831 --- /dev/null +++ b/src/apis/option/userApi.ts @@ -0,0 +1,7 @@ +import api from "@/apis/instance"; +import type { ApiResponse, NoResponse } from "@/types/common/apiResponse.type"; + +export const deleteMeApi = async (): ApiResponse => { + const res = await api.delete("/api/v1/users/me"); + return res.data; +}; diff --git a/src/apis/profile/guestbookApi.ts b/src/apis/profile/guestbookApi.ts new file mode 100644 index 0000000..69e902a --- /dev/null +++ b/src/apis/profile/guestbookApi.ts @@ -0,0 +1,21 @@ +import api from "@/apis/instance"; +import type { ApiResponse, NoResponse } from "@/types/common/apiResponse.type"; +import type { + CreateGuestbookRequest, + GuestbookEntry, +} from "@/types/profile/guestbookApi.type"; + +export const getGuestbookList = async ( + userId: string | number +): ApiResponse => { + const res = await api.get(`/api/v1/users/guestbook/${userId}/list`); + return res.data; +}; + +export const postGuestbook = async ( + userId: string | number, + body: CreateGuestbookRequest +): ApiResponse => { + const res = await api.post(`/api/v1/users/${userId}/guestbook`, body); + return res.data; +}; diff --git a/src/apis/profile/profileApi.ts b/src/apis/profile/profileApi.ts new file mode 100644 index 0000000..a28ac2b --- /dev/null +++ b/src/apis/profile/profileApi.ts @@ -0,0 +1,27 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; +import type { + FriendWaterResponse, + GetUserProfileResponse, +} from "@/types/profile/profileApi.type"; + +export const getUserProfile = async ( + userId: string | number +): ApiResponse => { + const res = await api.get(`/api/v1/users/${userId}`); + return res.data; +}; + +export const postFriendWater = async ( + gardenId: number +): ApiResponse => { + const res = await api.post(`/api/v1/gardens/${gardenId}/friendwater`); + return res.data; +}; + +export const patchMyNickname = async (newNickname: string): ApiResponse => { + const res = await api.patch("/api/v1/users/me/nickname", { + newNickname, + }); + return res.data; +}; diff --git a/src/apis/register/refreshApi.ts b/src/apis/register/refreshApi.ts new file mode 100644 index 0000000..1e1368b --- /dev/null +++ b/src/apis/register/refreshApi.ts @@ -0,0 +1,17 @@ +import axios from "axios"; +import { PostRegisterResponse } from "@/types/apis/register"; +import { ApiResponse } from "@/types/common/apiResponse.type"; + +const API_URL = process.env.EXPO_PUBLIC_API_URL || "https://api.napulnapul.com"; + +const refreshClient = axios.create({ + baseURL: API_URL, +}); + +export const refreshAuthApi = async ( + refreshToken: string +): ApiResponse => { + return refreshClient + .post("/api/v1/auth/refresh", { refreshToken }) + .then(res => res.data); +}; diff --git a/src/apis/register/registerApi.ts b/src/apis/register/registerApi.ts index 1531ec1..44772bc 100644 --- a/src/apis/register/registerApi.ts +++ b/src/apis/register/registerApi.ts @@ -7,3 +7,12 @@ export const registerApi = async ( ): ApiResponse => { return axios.post("/api/v1/auth/signup", { nickname }).then(res => res.data); }; + +// [STEP 3] 백엔드 자체 인증 처리 API +// 프론트엔드에서 획득한 Supabase의 AccessToken을 백엔드로 보내어 +// 자체 서비스에서 사용하는 JWT(Access/Refresh Token)로 교환받습니다. +export const loginWithSupabaseApi = async ( + accessToken: string +): ApiResponse => { + return axios.post("/api/v1/auth/supabase", { accessToken }).then(res => res.data); +}; diff --git a/src/apis/report/reportApi.ts b/src/apis/report/reportApi.ts new file mode 100644 index 0000000..e8bd52d --- /dev/null +++ b/src/apis/report/reportApi.ts @@ -0,0 +1,10 @@ +import api from "@/apis/instance"; +import type { ApiResponse } from "@/types/common/apiResponse.type"; +import type { CreateReportPayload } from "@/types/report"; + +export const postReport = async ( + payload: CreateReportPayload +): ApiResponse => { + const res = await api.post("/api/v1/reports", payload); + return res.data; +}; diff --git a/src/apis/supabase.ts b/src/apis/supabase.ts new file mode 100644 index 0000000..6d30f8a --- /dev/null +++ b/src/apis/supabase.ts @@ -0,0 +1,57 @@ +import { AppState } from "react-native"; +import "react-native-url-polyfill/auto"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { debugLog } from "@/utils/debug"; + +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL?.trim() ?? ""; +const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY?.trim() ?? ""; + +export const getSupabaseConfigErrorMessage = () => { + if (!supabaseUrl || !supabaseAnonKey) { + return "Supabase 환경변수가 비어 있습니다. .env에 EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY를 채워주세요."; + } + + if ( + supabaseUrl === "YOUR_SUPABASE_URL" || + supabaseAnonKey === "YOUR_SUPABASE_ANON_KEY" + ) { + return "Supabase 환경변수가 placeholder 상태입니다. 실제 프로젝트 값을 입력해주세요."; + } + + return null; +}; + +export const SUPABASE_CONFIG_ERROR_MESSAGE = getSupabaseConfigErrorMessage(); +export const isSupabaseConfigured = !SUPABASE_CONFIG_ERROR_MESSAGE; + +if (isSupabaseConfigured) { + debugLog("Supabase", "Initializing client", { url: supabaseUrl }); +} else { + debugLog("Supabase", "Configuration missing", { + error: SUPABASE_CONFIG_ERROR_MESSAGE, + }); +} + +export const supabase = (isSupabaseConfigured + ? createClient(supabaseUrl, supabaseAnonKey, { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, + }) + : null) as SupabaseClient; + +if (isSupabaseConfigured) { + AppState.addEventListener("change", state => { + if (state === "active") { + debugLog("Supabase", "App active -> startAutoRefresh"); + supabase.auth.startAutoRefresh(); + } else { + debugLog("Supabase", "App background -> stopAutoRefresh"); + supabase.auth.stopAutoRefresh(); + } + }); +} diff --git a/src/assets/icons/CommonIcons.tsx b/src/assets/icons/CommonIcons.tsx index f35ea12..7a2d4a8 100644 --- a/src/assets/icons/CommonIcons.tsx +++ b/src/assets/icons/CommonIcons.tsx @@ -62,6 +62,50 @@ export function RightIcon({ size = 24, color = "#171717" }: IconProps) { ); } +// FE settings and follow designs use simple stroke-based utility icons not covered by the old set. +export function XmarkIcon({ size = 24, color = "#9CA3AF" }: IconProps) { + return ( + + + + + ); +} + +export function ToggleOnIcon({ size = 36 }: IconProps) { + return ( + + + + + ); +} + +export function ToggleOffIcon({ size = 36 }: IconProps) { + return ( + + + + + ); +} + // 하트 아이콘 (좋아요) export function HeartIcon({ size = 24, @@ -137,6 +181,42 @@ export function UserPlusIcon({ size = 24, color = "#171717" }: IconProps) { ); } +// 새로고침 아이콘 +export function RefreshIcon({ size = 24, color = "#171717" }: IconProps) { + return ( + + + + + + + ); +} + // 전송 아이콘 export function SendIcon({ size = 32 }: IconProps) { return ( diff --git a/src/assets/icons/common/index.ts b/src/assets/icons/common/index.ts index bf4c18a..140385f 100644 --- a/src/assets/icons/common/index.ts +++ b/src/assets/icons/common/index.ts @@ -1,27 +1,27 @@ -export { default as BookMark } from "./bookmark1.svg?react"; -export { default as ActiveBookMark } from "./bookmark2.svg?react"; -export { default as ActiveCalendar } from "./calendar1.svg?react"; -export { default as Calendar } from "./calendar2.svg?react"; -export { default as Camera } from "./camera.svg?react"; -export { default as Chat } from "./chat.svg?react"; -export { default as Check } from "./Check.svg?react"; -export { default as UnCheck } from "./Check2.svg?react"; -export { default as Edit } from "./edit.svg?react"; -export { default as Heart } from "./heart.svg?react"; -export { default as ActiveHome } from "./home1.svg?react"; -export { default as Home } from "./home2.svg?react"; -export { default as Left } from "./left.svg?react"; -export { default as Level0 } from "./level0.svg?react"; -export { default as Level1 } from "./level1.svg?react"; -export { default as Level2 } from "./level2.svg?react"; -export { default as Level3 } from "./level3.svg?react"; -export { default as Right } from "./right.svg?react"; -export { default as ActiveSearch } from "./search1.svg?react"; -export { default as Search } from "./search2.svg?react"; -export { default as Send } from "./send.svg?react"; -export { default as TogleOn } from "./togle1.svg?react"; -export { default as TogleOff } from "./togle2.svg?react"; -export { default as ActiveUser } from "./user1.svg?react"; -export { default as User } from "./user2.svg?react"; -export { default as UserPlus } from "./userPlus.svg?react"; -export { default as Xmark } from "./Xmack.svg?react"; +export { default as BookMark } from "./bookmark1.svg"; +export { default as ActiveBookMark } from "./bookmark2.svg"; +export { default as ActiveCalendar } from "./calendar1.svg"; +export { default as Calendar } from "./calendar2.svg"; +export { default as Camera } from "./camera.svg"; +export { default as Chat } from "./chat.svg"; +export { default as Check } from "./Check.svg"; +export { default as UnCheck } from "./Check2.svg"; +export { default as Edit } from "./edit.svg"; +export { default as Heart } from "./heart.svg"; +export { default as ActiveHome } from "./home1.svg"; +export { default as Home } from "./home2.svg"; +export { default as Left } from "./left.svg"; +export { default as Level0 } from "./level0.svg"; +export { default as Level1 } from "./level1.svg"; +export { default as Level2 } from "./level2.svg"; +export { default as Level3 } from "./level3.svg"; +export { default as Right } from "./right.svg"; +export { default as ActiveSearch } from "./search1.svg"; +export { default as Search } from "./search2.svg"; +export { default as Send } from "./send.svg"; +export { default as TogleOn } from "./togle1.svg"; +export { default as TogleOff } from "./togle2.svg"; +export { default as ActiveUser } from "./user1.svg"; +export { default as User } from "./user2.svg"; +export { default as UserPlus } from "./userPlus.svg"; +export { default as Xmark } from "./Xmack.svg"; diff --git a/src/assets/icons/sun.svg b/src/assets/icons/sun.svg index 9b9e46f..633e77b 100644 --- a/src/assets/icons/sun.svg +++ b/src/assets/icons/sun.svg @@ -1,6 +1,6 @@ - + diff --git a/src/assets/icons/water.svg b/src/assets/icons/water.svg index a000625..a41f505 100644 --- a/src/assets/icons/water.svg +++ b/src/assets/icons/water.svg @@ -1,6 +1,6 @@ - + diff --git a/src/assets/images/char.webp b/src/assets/images/char-emotion.webp similarity index 100% rename from src/assets/images/char.webp rename to src/assets/images/char-emotion.webp diff --git a/src/assets/images/char2.webp b/src/assets/images/char-stage.webp similarity index 100% rename from src/assets/images/char2.webp rename to src/assets/images/char-stage.webp diff --git a/src/assets/images/flower.png b/src/assets/images/flower.png new file mode 100644 index 0000000..da6a7d9 Binary files /dev/null and b/src/assets/images/flower.png differ diff --git a/src/assets/images/fruit.png b/src/assets/images/fruit.png new file mode 100644 index 0000000..c68ff55 Binary files /dev/null and b/src/assets/images/fruit.png differ diff --git a/src/assets/images/sprout.png b/src/assets/images/sprout.png new file mode 100644 index 0000000..2c721c9 Binary files /dev/null and b/src/assets/images/sprout.png differ diff --git a/src/assets/images/tree.png b/src/assets/images/tree.png new file mode 100644 index 0000000..5c39e32 Binary files /dev/null and b/src/assets/images/tree.png differ diff --git a/src/components/common/Comment.tsx b/src/components/common/Comment.tsx index 30c1114..b2c0618 100644 --- a/src/components/common/Comment.tsx +++ b/src/components/common/Comment.tsx @@ -1,12 +1,20 @@ import React from "react"; -import { View, Text, Image, StyleSheet } from "react-native"; +import { View, Text, Image, StyleSheet, TouchableOpacity } from "react-native"; import type { CommentItem } from "@/types/log/diary"; type Props = { comment: CommentItem; + actionLabel?: string; + onActionPress?: () => void; + actionDisabled?: boolean; }; -export default function Comment({ comment }: Props) { +export default function Comment({ + comment, + actionLabel, + onActionPress, + actionDisabled = false, +}: Props) { return ( {/* 프로필 이미지 */} @@ -28,6 +36,17 @@ export default function Comment({ comment }: Props) { {comment.writer} + {actionLabel && onActionPress ? ( + + + {actionLabel} + + + ) : null} {/* 댓글 내용 */} @@ -82,6 +101,14 @@ const styles = StyleSheet.create({ fontWeight: "600", color: "#171717", }, + reportText: { + fontSize: 12, + fontWeight: "600", + color: "#6B7280", + }, + reportTextDisabled: { + color: "#9CA3AF", + }, commentContent: { fontSize: 14, color: "#171717", diff --git a/src/components/common/CommentComposer.tsx b/src/components/common/CommentComposer.tsx new file mode 100644 index 0000000..d991c21 --- /dev/null +++ b/src/components/common/CommentComposer.tsx @@ -0,0 +1,77 @@ +import { + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { SendIcon } from "@/assets/icons/CommonIcons"; + +type Props = { + value: string; + onChangeText: (text: string) => void; + onSubmit: () => void; + disabled?: boolean; + placeholder?: string; +}; + +export default function CommentComposer({ + value, + onChangeText, + onSubmit, + disabled = false, + placeholder = "댓글을 입력해주세요.", +}: Props) { + const canSubmit = !disabled && value.trim().length > 0; + + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + borderTopWidth: 1, + borderTopColor: "#E5E7EB", + backgroundColor: "#FFFFFF", + paddingHorizontal: 20, + paddingVertical: 12, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + input: { + flex: 1, + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: "#E5E7EB", + borderRadius: 20, + fontSize: 14, + color: "#171717", + }, + sendDisabled: { + opacity: 0.45, + }, +}); diff --git a/src/components/common/ConfirmModal.tsx b/src/components/common/ConfirmModal.tsx new file mode 100644 index 0000000..4d26248 --- /dev/null +++ b/src/components/common/ConfirmModal.tsx @@ -0,0 +1,133 @@ +import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from "react-native"; + +type Props = { + visible: boolean; + title: string; + description: string; + cancelLabel?: string; + confirmLabel: string; + confirmDestructive?: boolean; + confirmDisabled?: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +export default function ConfirmModal({ + visible, + title, + description, + cancelLabel = "취소", + confirmLabel, + confirmDestructive = false, + confirmDisabled = false, + onCancel, + onConfirm, +}: Props) { + return ( + + + + + + {title} + {description} + + + + + {cancelLabel} + + + {confirmLabel} + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.38)", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + }, + card: { + width: "100%", + maxWidth: 355, + borderRadius: 12, + backgroundColor: "#FFFFFF", + paddingHorizontal: 24, + paddingTop: 28, + paddingBottom: 24, + gap: 32, + }, + copyGroup: { + width: "100%", + gap: 8, + }, + title: { + fontSize: 20, + lineHeight: 28, + fontWeight: "600", + color: "#171717", + }, + description: { + fontSize: 16, + lineHeight: 26, + color: "#171717", + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + cancelButton: { + flex: 1, + height: 57, + borderRadius: 8, + backgroundColor: "#EFEFEF", + alignItems: "center", + justifyContent: "center", + }, + cancelLabel: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#171717", + }, + confirmButton: { + flex: 1, + height: 57, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }, + confirmButtonPrimary: { + backgroundColor: "#72D14E", + }, + confirmButtonDanger: { + backgroundColor: "#F76868", + }, + confirmButtonDisabled: { + opacity: 0.55, + }, + confirmLabel: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, +}); diff --git a/src/components/common/ScreenHeader.tsx b/src/components/common/ScreenHeader.tsx new file mode 100644 index 0000000..1516201 --- /dev/null +++ b/src/components/common/ScreenHeader.tsx @@ -0,0 +1,102 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import LeftIcon from "@/assets/icons/common/left.svg"; + +const refreshIcon = require("../../../assets/refresh-icon.png"); + +type Props = { + title: string; + onBack?: () => void; + rightActionLabel?: string; + onRightAction?: () => void; + rightActionDisabled?: boolean; +}; + +export default function ScreenHeader({ + title, + onBack, + rightActionLabel, + onRightAction, + rightActionDisabled = false, +}: Props) { + const resolvedRightLabel = rightActionLabel ?? "완료"; + const isRefreshAction = resolvedRightLabel === "새로고침"; + + return ( + + {onBack ? ( + + + + ) : ( + + )} + + {title} + + {onRightAction ? ( + + {isRefreshAction ? ( + + ) : ( + + {resolvedRightLabel} + + )} + + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + header: { + height: 56, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + backgroundColor: "#FFFFFF", + borderBottomWidth: 1, + borderBottomColor: "#EFEFEF", + }, + sideButton: { + width: 48, + height: 44, + justifyContent: "center", + alignItems: "center", + }, + refreshIcon: { + width: 22, + height: 22, + }, + refreshIconDisabled: { + opacity: 0.4, + }, + completeText: { + fontSize: 16, + fontWeight: "400", + color: "#171717", + textAlign: "right", + }, + disabledText: { + color: "#BFBFBF", + }, + title: { + fontSize: 18, + fontWeight: "600", + color: "#171717", + }, +}); diff --git a/src/components/common/Splash.tsx b/src/components/common/Splash.tsx index b20d6c4..b0f765d 100644 --- a/src/components/common/Splash.tsx +++ b/src/components/common/Splash.tsx @@ -1,4 +1,4 @@ -import { View, Text, ImageBackground, Image, StyleSheet } from "react-native"; +import { View, Text, ImageBackground, Image, StyleSheet } from "react-native"; const SplashBg = require("@/assets/images/onboarding/splash.png"); const Character = require("@/assets/images/char.png"); diff --git a/src/components/common/StatusView.tsx b/src/components/common/StatusView.tsx new file mode 100644 index 0000000..947af9e --- /dev/null +++ b/src/components/common/StatusView.tsx @@ -0,0 +1,74 @@ +import { + ActivityIndicator, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; + +type Props = { + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + loading?: boolean; +}; + +export default function StatusView({ + title, + description, + actionLabel, + onAction, + loading = false, +}: Props) { + return ( + + {loading ? : null} + {title} + {description ? {description} : null} + {actionLabel && onAction ? ( + + {actionLabel} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + gap: 8, + }, + title: { + fontSize: 16, + fontWeight: "600", + color: "#171717", + textAlign: "center", + }, + description: { + fontSize: 14, + lineHeight: 20, + color: "#6B7280", + textAlign: "center", + }, + button: { + marginTop: 8, + backgroundColor: "#4CAF50", + borderRadius: 999, + paddingHorizontal: 16, + paddingVertical: 10, + }, + buttonText: { + color: "#FFFFFF", + fontSize: 14, + fontWeight: "600", + }, +}); diff --git a/src/components/dailyMission/ImageAttachmentCard.tsx b/src/components/dailyMission/ImageAttachmentCard.tsx new file mode 100644 index 0000000..840aba4 --- /dev/null +++ b/src/components/dailyMission/ImageAttachmentCard.tsx @@ -0,0 +1,93 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; + +type Props = { + imageUrl?: string | null; + disabled?: boolean; + onPress: () => void; + helperText?: string; +}; + +export default function ImageAttachmentCard({ + imageUrl, + disabled = false, + onPress, + helperText, +}: Props) { + return ( + + + {imageUrl ? ( + // 선택된 이미지 미리보기 + + ) : ( + // 이미지 미선택 안내 플레이스홀더 + + 📷 + + 화분을 예쁘게 가꾸고 + 친구들에게 멋진 식물을 자랑해보아요! + + + )} + + {helperText ? {helperText} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 8, + }, + imageArea: { + width: "100%", + borderRadius: 16, + overflow: "hidden", + backgroundColor: "#EDEDED", + }, + imageAreaDisabled: { + opacity: 0.6, + }, + // 이미지 높이 353px (피그마 기준) + previewImage: { + width: "100%", + height: 353, + }, + placeholderBox: { + height: 353, + alignItems: "center", + justifyContent: "center", + gap: 4, + }, + cameraIcon: { + fontSize: 32, + opacity: 0.45, + }, + // 플레이스홀더 텍스트 묶음 + placeholderTextGroup: { + alignItems: "center", + gap: 0, + }, + // 플레이스홀더 텍스트: 14px #7C7C7C + placeholderText: { + fontSize: 14, + color: "#7C7C7C", + lineHeight: 14 * 1.6, + textAlign: "center", + }, + helperText: { + fontSize: 12, + lineHeight: 18, + color: "#92400E", + paddingHorizontal: 4, + }, +}); diff --git a/src/components/dailyMission/OxQuizOptionCard.tsx b/src/components/dailyMission/OxQuizOptionCard.tsx new file mode 100644 index 0000000..d7bff55 --- /dev/null +++ b/src/components/dailyMission/OxQuizOptionCard.tsx @@ -0,0 +1,72 @@ +import { StyleSheet, TouchableOpacity, View } from "react-native"; +import OIcon from "@/assets/icons/common/OX_O.svg"; +import XIcon from "@/assets/icons/common/OX_X.svg"; + +type OxOptionState = "idle" | "selected" | "correct" | "wrong" | "answer"; + +type Props = { + label: "O" | "X"; + state?: OxOptionState; + disabled?: boolean; + onPress: () => void; +}; + +export default function OxQuizOptionCard({ + label, + state = "idle", + disabled = false, + onPress, +}: Props) { + const Icon = label === "O" ? OIcon : XIcon; + const isCorrect = state === "correct" || state === "answer"; + const isWrong = state === "wrong"; + const isSelected = state === "selected"; + const iconColor = isCorrect ? "#3AB40B" : isWrong ? "#F76868" : isSelected ? "#171717" : "#BFBFBF"; + + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + flex: 1, + height: 160, + borderRadius: 8, + borderWidth: 1, + borderColor: "#EFEFEF", + backgroundColor: "#FFFFFF", + alignItems: "center", + justifyContent: "center", + }, + cardSelected: { + backgroundColor: "#EFEFEF", + borderColor: "#BFBFBF", + }, + cardCorrect: { + backgroundColor: "#EEF9EA", + borderColor: "#72D14E", + }, + cardWrong: { + backgroundColor: "#FFEFEF", + borderColor: "#F76868", + }, + iconWrap: { + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/src/components/dailyMission/QuizOptionCard.tsx b/src/components/dailyMission/QuizOptionCard.tsx new file mode 100644 index 0000000..a9cc3d6 --- /dev/null +++ b/src/components/dailyMission/QuizOptionCard.tsx @@ -0,0 +1,102 @@ +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import CheckIcon from "@/assets/icons/Check.svg"; +import Check2Icon from "@/assets/icons/Check2.svg"; + +type Props = { + label: string; + selected: boolean; + disabled?: boolean; + state?: "correct" | "wrong" | "answer" | "idle"; + onPress: () => void; +}; + +export default function QuizOptionCard({ + label, + selected, + disabled = false, + state = "idle", + onPress, +}: Props) { + const isCorrect = state === "correct" || state === "answer"; + const isWrong = state === "wrong"; + const isSelected = selected && !isCorrect && !isWrong; + const BadgeIcon = isCorrect || isSelected ? CheckIcon : Check2Icon; + const badgeText = isCorrect ? "정답!" : isWrong ? "오답!" : null; + + return ( + + {label} + + {badgeText ? ( + + {badgeText} + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + card: { + minHeight: 60, + borderRadius: 8, + borderWidth: 1, + borderColor: "#EFEFEF", + paddingLeft: 24, + paddingRight: 16, + paddingVertical: 16, + backgroundColor: "#FFFFFF", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + cardSelected: { + backgroundColor: "#EFEFEF", + borderColor: "#BFBFBF", + }, + cardCorrect: { + backgroundColor: "#EEF9EA", + borderColor: "#72D14E", + }, + cardWrong: { + backgroundColor: "#FFEFEF", + borderColor: "#F76868", + }, + text: { + flex: 1, + fontSize: 16, + lineHeight: 26, + color: "#282828", + fontWeight: "400", + }, + badgeWrap: { + minWidth: 60, + alignItems: "flex-end", + justifyContent: "center", + }, + badgeText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + }, + badgeTextCorrect: { + color: "#3AB40B", + }, + badgeTextWrong: { + color: "#F76868", + }, +}); diff --git a/src/components/dailyMission/QuizResultCard.tsx b/src/components/dailyMission/QuizResultCard.tsx new file mode 100644 index 0000000..a5aaf4e --- /dev/null +++ b/src/components/dailyMission/QuizResultCard.tsx @@ -0,0 +1,46 @@ +import { StyleSheet, Text, View } from "react-native"; + +type Props = { + correct: boolean; + description: string; +}; + +export default function QuizResultCard({ correct, description }: Props) { + return ( + + + {correct ? "정답!" : "오답!"} + + {description} + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 16, + padding: 16, + gap: 6, + }, + correct: { + backgroundColor: "#EDF7ED", + }, + wrong: { + backgroundColor: "#FEF2F2", + }, + title: { + fontSize: 16, + fontWeight: "700", + }, + correctText: { + color: "#1F5C27", + }, + wrongText: { + color: "#B91C1C", + }, + description: { + fontSize: 14, + lineHeight: 20, + color: "#4B5563", + }, +}); diff --git a/src/components/delivery/DeliveryRequestSelector.tsx b/src/components/delivery/DeliveryRequestSelector.tsx new file mode 100644 index 0000000..771a534 --- /dev/null +++ b/src/components/delivery/DeliveryRequestSelector.tsx @@ -0,0 +1,104 @@ +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import DeliveryTextField from "@/components/delivery/DeliveryTextField"; + +export const DELIVERY_REQUEST_OPTIONS = [ + "문 앞에 놔주세요", + "경비실에 맡겨주세요", + "택배함에 넣어주세요", + "배송 전에 연락 주세요", + "직접 입력", +] as const; + +type Props = { + value: string; + customValue: string; + onChange: (value: string) => void; + onChangeCustom: (value: string) => void; +}; + +export default function DeliveryRequestSelector({ + value, + customValue, + onChange, + onChangeCustom, +}: Props) { + return ( + + 배송 요청사항 + + 현재 확인된 계약 기준으로 요청 문구만 서버에 전송합니다. + + + {DELIVERY_REQUEST_OPTIONS.map(option => { + const selected = value === option; + + return ( + onChange(option)} + > + + {option} + + + ); + })} + + {value === "직접 입력" ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 10, + }, + title: { + fontSize: 16, + fontWeight: "700", + color: "#171717", + }, + subtitle: { + fontSize: 13, + lineHeight: 18, + color: "#6B7280", + }, + options: { + gap: 10, + }, + option: { + borderWidth: 1, + borderColor: "#D1D5DB", + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 14, + backgroundColor: "#FFFFFF", + }, + optionSelected: { + borderColor: "#2F7D32", + backgroundColor: "#EDF7ED", + }, + optionText: { + fontSize: 14, + color: "#374151", + }, + optionTextSelected: { + color: "#1F5C27", + fontWeight: "700", + }, +}); diff --git a/src/components/delivery/DeliveryTextField.tsx b/src/components/delivery/DeliveryTextField.tsx new file mode 100644 index 0000000..2a115a1 --- /dev/null +++ b/src/components/delivery/DeliveryTextField.tsx @@ -0,0 +1,74 @@ +import { + StyleSheet, + Text, + TextInput, + type KeyboardTypeOptions, + View, +} from "react-native"; + +type Props = { + label: string; + value: string; + onChangeText: (value: string) => void; + placeholder: string; + keyboardType?: KeyboardTypeOptions; + multiline?: boolean; + helperText?: string; +}; + +export default function DeliveryTextField({ + label, + value, + onChangeText, + placeholder, + keyboardType = "default", + multiline = false, + helperText, +}: Props) { + return ( + + {label} + + {helperText ? {helperText} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 8, + }, + label: { + fontSize: 14, + fontWeight: "600", + color: "#374151", + }, + input: { + minHeight: 52, + borderWidth: 1, + borderColor: "#D1D5DB", + borderRadius: 14, + paddingHorizontal: 14, + fontSize: 15, + color: "#171717", + backgroundColor: "#FFFFFF", + }, + multiline: { + minHeight: 92, + paddingVertical: 14, + }, + helperText: { + fontSize: 12, + lineHeight: 18, + color: "#6B7280", + }, +}); diff --git a/src/components/delivery/GardenSlotCard.tsx b/src/components/delivery/GardenSlotCard.tsx new file mode 100644 index 0000000..d1d6d0b --- /dev/null +++ b/src/components/delivery/GardenSlotCard.tsx @@ -0,0 +1,100 @@ +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { + getGardenLocked, + getGardenUnlockable, + type GardenSummary, +} from "@/types/home/garden"; + +type Props = { + garden: GardenSummary; + selected: boolean; + onPress: () => void; +}; + +export default function GardenSlotCard({ garden, selected, onPress }: Props) { + const isUnlockable = getGardenUnlockable(garden); + const isLocked = getGardenLocked(garden); + const statusText = isUnlockable ? "해금 가능" : isLocked ? "아직 잠금 상태" : "사용 가능"; + + return ( + + + 정원 슬롯 {garden.gardenSlotNumber} + + {statusText} + + + + {garden.avatar?.avatarName + ? `${garden.avatar.avatarName}와 연결된 슬롯` + : "새 식물을 받을 수 있는 잠금 슬롯"} + + + 해금 버튼을 누르면 `POST /api/v1/gardens/unlock`를 body 없이 호출합니다. + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 18, + borderWidth: 1, + borderColor: "#E5E7EB", + backgroundColor: "#FFFFFF", + padding: 16, + gap: 8, + }, + cardSelected: { + borderColor: "#2F7D32", + backgroundColor: "#F2FAF1", + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: 12, + }, + title: { + flex: 1, + fontSize: 16, + fontWeight: "700", + color: "#171717", + }, + badge: { + fontSize: 12, + fontWeight: "700", + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 999, + overflow: "hidden", + }, + badgeReady: { + color: "#1F5C27", + backgroundColor: "#DDF3DE", + }, + badgeLocked: { + color: "#92400E", + backgroundColor: "#FEF3C7", + }, + description: { + fontSize: 14, + lineHeight: 20, + color: "#374151", + }, + caption: { + fontSize: 12, + lineHeight: 18, + color: "#6B7280", + }, +}); diff --git a/src/components/delivery/PlantOptionCard.tsx b/src/components/delivery/PlantOptionCard.tsx new file mode 100644 index 0000000..410c983 --- /dev/null +++ b/src/components/delivery/PlantOptionCard.tsx @@ -0,0 +1,67 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import type { DeliverablePlant } from "@/types/delivery"; + +type Props = { + plant: DeliverablePlant; + selected: boolean; + onPress: () => void; +}; + +export default function PlantOptionCard({ plant, selected, onPress }: Props) { + return ( + + {plant.imageUrl ? ( + + ) : ( + + 이미지 없음 + + )} + {plant.name} + 씨앗 타입 #{plant.seedType} + + ); +} + +const styles = StyleSheet.create({ + card: { + width: 180, + borderRadius: 20, + padding: 14, + borderWidth: 1, + borderColor: "#E5E7EB", + backgroundColor: "#FFFFFF", + gap: 10, + }, + cardSelected: { + borderColor: "#2F7D32", + backgroundColor: "#F2FAF1", + }, + image: { + width: "100%", + height: 180, + borderRadius: 16, + backgroundColor: "#F3F4F6", + }, + imageFallback: { + alignItems: "center", + justifyContent: "center", + }, + fallbackText: { + fontSize: 13, + color: "#6B7280", + }, + name: { + fontSize: 16, + fontWeight: "700", + color: "#171717", + }, + caption: { + fontSize: 12, + color: "#6B7280", + }, +}); diff --git a/src/components/feed/FeedAvatarDetailCard.tsx b/src/components/feed/FeedAvatarDetailCard.tsx new file mode 100644 index 0000000..5e1c138 --- /dev/null +++ b/src/components/feed/FeedAvatarDetailCard.tsx @@ -0,0 +1,166 @@ +import type { AxiosError } from "axios"; +import { useState } from "react"; +import { ActivityIndicator, Alert, StyleSheet, Text, View } from "react-native"; +import FeedDetail from "@/components/feed/FeedDetail"; +import usePostComment, { useDeleteComment } from "@/hooks/comments/useCommentApi"; +import useFeedLikeToggle from "@/hooks/feed/useFeedLikeToggle"; +import { useAvatarPostDetail } from "@/hooks/feed/useAvatarPostDetailApi"; +import { useCreateReport } from "@/hooks/report/useReportApi"; +import type { FeedDetailResult } from "@/types/feed/detail"; + +type Props = { + postId: number; +}; + +export default function FeedAvatarDetailCard({ postId }: Props) { + const { data, isLoading, error, refetch } = useAvatarPostDetail(postId); + const [content, setContent] = useState(""); + const [isHidden, setIsHidden] = useState(false); + const { mutateAsync, isPending } = usePostComment(() => void refetch()); + const deleteCommentMutation = useDeleteComment(() => void refetch()); + const reportMutation = useCreateReport(); + const { liked, likeCount, toggleLike, isLikePending } = useFeedLikeToggle({ + targetId: postId, + targetType: "AVATAR_POST", + initialLiked: data?.isLiked ?? false, + initialLikeCount: data?.likeCount ?? 0, + onSuccessRefetch: refetch, + }); + + const handleSendComment = async () => { + if (!content.trim()) { + return; + } + + try { + await mutateAsync({ content, targetId: postId, targetType: "AVATAR_POST" }); + setContent(""); + } catch (commentError) { + console.error("[FeedAvatarDetailCard] Failed to post comment:", commentError); + } + }; + + const handleReportPost = async () => { + try { + await reportMutation.mutateAsync({ + targetType: "AVATAR_POST", + targetId: postId, + reason: "부적절한 콘텐츠", + additionalComment: "", + }); + setIsHidden(true); + Alert.alert("신고 완료", "신고가 접수되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("신고 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + const handleReportComment = async (commentId: number) => { + try { + await reportMutation.mutateAsync({ + targetType: "COMMENT", + targetId: commentId, + reason: "부적절한 댓글", + additionalComment: "", + }); + Alert.alert("신고 완료", "신고가 접수되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("신고 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + const handleDeleteComment = async (commentId: number) => { + try { + await deleteCommentMutation.mutateAsync({ commentId, targetType: "AVATAR_POST", targetId: postId }); + Alert.alert("삭제 완료", "댓글이 삭제되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("삭제 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + if (isLoading) { + return ( + + + 아바타 포스트 상세를 불러오는 중입니다. + + ); + } + + if (error || !data) { + return ( + + 아바타 포스트 상세를 불러오지 못했습니다. + + ); + } + + if (isHidden) { + return ( + + 신고한 게시물입니다. + 이 항목은 나에게 숨김 처리되었습니다. + + ); + } + + const result: FeedDetailResult = { + id: data.id, + writerId: data.writerId, + writerName: data.writerName, + profileImageUrl: data.profileImageUrl, + content: data.content, + imageUrl: data.imageUrl, + isLiked: data.isLiked, + likeCount: data.likeCount, + commentCount: data.commentCount, + comments: data.comments, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + isPublic: data.isPublic, + }; + + return ( + void handleSendComment()} + isCommentPending={isPending} + onPressReport={handleReportPost} + onPressCommentReport={handleReportComment} + onPressCommentDelete={handleDeleteComment} + isReportPending={reportMutation.isPending || deleteCommentMutation.isPending} + /> + ); +} + +const styles = StyleSheet.create({ + statusCard: { + paddingHorizontal: 20, + paddingVertical: 32, + alignItems: "center", + gap: 12, + }, + statusText: { + fontSize: 14, + color: "#6B7280", + textAlign: "center", + }, + hiddenTitle: { + fontSize: 15, + fontWeight: "700", + color: "#171717", + }, +}); diff --git a/src/components/feed/FeedDetail.tsx b/src/components/feed/FeedDetail.tsx index aa56751..ab1ae80 100644 --- a/src/components/feed/FeedDetail.tsx +++ b/src/components/feed/FeedDetail.tsx @@ -1,26 +1,169 @@ -import React from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { - View, - Text, + FlatList, Image, - TouchableOpacity, + KeyboardAvoidingView, + ListRenderItem, + Platform, StyleSheet, + Text, + TouchableOpacity, + View, } from "react-native"; import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { + BottomSheetBackdrop, + BottomSheetFooter, + BottomSheetFlatList, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; import type { RootStackParamList } from "@/navigation/types"; import { HeartIcon, ChatIcon } from "@/assets/icons/CommonIcons"; import Comment from "@/components/common/Comment"; +import CommentComposer from "@/components/common/CommentComposer"; +import ConfirmModal from "@/components/common/ConfirmModal"; +import useTokenStore from "@/stores/useTokenStore"; import type { FeedDetailResult } from "@/types/feed/detail"; type Props = { result: FeedDetailResult; + reportTargetLabel?: string; + liked?: boolean; + likeCount?: number; + onToggleLike?: () => void; + isLikePending?: boolean; + commentValue?: string; + onChangeComment?: (text: string) => void; + onSubmitComment?: () => void; + isCommentPending?: boolean; + onPressReport?: () => Promise | void; + onPressCommentReport?: (commentId: number, writer: string) => Promise | void; + onPressCommentDelete?: (commentId: number) => Promise | void; + isReportPending?: boolean; +}; + +type ConfirmModalState = { + title: string; + description: string; + confirmLabel: string; + destructive?: boolean; + onConfirm: () => Promise | ConfirmModalState | void; }; -export default function FeedDetail({ result }: Props) { +export default function FeedDetail({ + result, + reportTargetLabel = "게시물", + liked = result.isLiked, + likeCount = result.likeCount, + onToggleLike, + isLikePending = false, + commentValue = "", + onChangeComment, + onSubmitComment, + isCommentPending = false, + onPressReport, + onPressCommentReport, + onPressCommentDelete, + isReportPending = false, +}: Props) { const navigation = useNavigation>(); + const myUserId = useTokenStore(state => state.userId); + const bottomSheetModalRef = useRef(null); + const snapPoints = useMemo(() => ["60%", "90%"], []); + const [hiddenCommentIds, setHiddenCommentIds] = useState([]); + const [confirmModalState, setConfirmModalState] = useState(null); + const [isConfirmPending, setIsConfirmPending] = useState(false); + + const comments = useMemo( + () => + result.comments + .filter(c => !hiddenCommentIds.includes(c.commentId)) + .map(c => ({ + id: c.commentId, + writerId: c.writerId, + profileImageUrl: c.profileImageUrl, + writer: c.writer, + content: c.content, + })), + [hiddenCommentIds, result.comments] + ); + + const handleCommentReportPress = async (commentId: number, writer: string) => { + if (!onPressCommentReport || isReportPending) { + return; + } + + setConfirmModalState({ + title: "신고하기", + description: "댓글을 신고하시겠습니까?\n신고한 댓글은 나에게 숨겨집니다.", + confirmLabel: "신고하기", + destructive: true, + onConfirm: () => ({ + title: "사용자 숨기기", + description: "사용자의 모든 댓글을 숨기시겠습니까?\n이 작업은 취소할 수 없습니다.", + confirmLabel: "숨기기", + destructive: true, + onConfirm: async () => { + try { + await onPressCommentReport(commentId, writer); + setHiddenCommentIds(previous => + previous.includes(commentId) ? previous : [...previous, commentId] + ); + } catch { + // Error handling is delegated to the caller. + } + }, + }), + }); + }; + + const renderCommentItem: ListRenderItem<(typeof comments)[number]> = ({ + item, + }) => { + const isOwnComment = Boolean(myUserId && String(item.writerId) === myUserId); + + return ( + { + if (!onPressCommentDelete || isReportPending) { + return; + } + + setConfirmModalState({ + title: "삭제하기", + description: "댓글을 삭제하시겠습니까?", + confirmLabel: "삭제", + destructive: true, + onConfirm: async () => { + try { + await onPressCommentDelete(item.id); + setHiddenCommentIds(previous => + previous.includes(item.id) ? previous : [...previous, item.id] + ); + } catch { + // Error handling is delegated to the caller. + } + }, + }); + } + : onPressCommentReport + ? () => void handleCommentReportPress(item.id, item.writer) + : undefined + } + actionDisabled={isReportPending} + /> + ); + }; + + const getCommentKey = (item: (typeof comments)[number]) => item.id.toString(); const formatDate = (iso: string) => { const d = new Date(iso); @@ -31,74 +174,208 @@ export default function FeedDetail({ result }: Props) { navigation.navigate("Profile", { userId: result.writerId }); }; - return ( - - {/* 작성자 영역 */} - - - - {result.profileImageUrl && ( - - )} - - - {result.writerName} - {formatDate(result.createdAt)} - - - 신고 - + const handleOpenComments = () => { + bottomSheetModalRef.current?.present(); + }; + + const handleReportPress = () => { + if (!onPressReport || isReportPending) { + return; + } + + setConfirmModalState({ + title: "신고하기", + description: `${reportTargetLabel}을 신고하시겠습니까?\n신고한 ${reportTargetLabel}은 나에게 숨겨집니다.`, + confirmLabel: "신고하기", + destructive: true, + onConfirm: () => ({ + title: "사용자 숨기기", + description: `사용자의 모든 ${reportTargetLabel}을 숨기시겠습니까?\n이 작업은 취소할 수 없습니다.`, + confirmLabel: "숨기기", + destructive: true, + onConfirm: async () => { + void onPressReport(); + }, + }), + }); + }; + + const handleConfirmModal = async () => { + if (!confirmModalState || isConfirmPending) { + return; + } + + const activeState = confirmModalState; + setIsConfirmPending(true); + + try { + const nextState = await activeState.onConfirm(); + setConfirmModalState(nextState ?? null); + } finally { + setIsConfirmPending(false); + } + }; + + const handleCloseComments = () => { + bottomSheetModalRef.current?.dismiss(); + }; - {/* 이미지 */} - ) => ( + + ), + [] + ); - {/* 내용 */} - {result.content} + const renderFooter = useCallback( + (props: React.ComponentProps) => { + if (!onChangeComment || !onSubmitComment) { + return null; + } - {/* 액션 바 */} - - - {/* 공감 */} - - - 공감 {result.likeCount} + return ( + + + - {/* 댓글 */} - - - 댓글 {result.commentCount} + + ); + }, + [commentValue, isCommentPending, onChangeComment, onSubmitComment] + ); + + return ( + <> + + + + + {result.profileImageUrl && ( + + )} + + + {result.writerName} + {formatDate(result.createdAt)} + + + + + {isReportPending ? "신고 중..." : "신고"} + + + + + + + {result.content} + + + + + + + 공감 {likeCount} + + + + + 댓글 {comments.length} + + - - {/* 댓글 목록 */} - - {result.comments.map(c => ( - - ))} - - + + + + 댓글 {comments.length} + + {comments.length > 0 ? ( + + data={comments} + keyExtractor={getCommentKey} + renderItem={renderCommentItem} + style={styles.commentList} + contentContainerStyle={styles.commentListContent} + showsVerticalScrollIndicator={false} + /> + ) : ( + + 아직 작성된 댓글이 없습니다. + + )} + + + + + + setConfirmModalState(null)} + onConfirm={() => void handleConfirmModal()} + /> + ); } @@ -124,6 +401,8 @@ const styles = StyleSheet.create({ borderRadius: 16, backgroundColor: "#E5E7EB", overflow: "hidden", + alignItems: "center", + justifyContent: "center", }, profileImage: { width: "100%", @@ -134,7 +413,7 @@ const styles = StyleSheet.create({ }, writerName: { fontSize: 14, - fontWeight: "500", + fontWeight: "400", color: "#171717", }, createdAt: { @@ -147,17 +426,22 @@ const styles = StyleSheet.create({ fontWeight: "600", color: "#6B7280", }, + reportButtonDisabled: { + color: "#9CA3AF", + }, mainImage: { width: "100%", aspectRatio: 1, borderRadius: 12, marginBottom: 24, + backgroundColor: "#F3F4F6", }, content: { fontSize: 14, color: "#171717", - lineHeight: 22, + lineHeight: 20, marginBottom: 24, + backgroundColor: "#F3F4F6", }, actionBar: { flexDirection: "row", @@ -184,11 +468,62 @@ const styles = StyleSheet.create({ fontSize: 14, color: "#171717", }, + actionTextLiked: { + color: "#FF5D73", + }, spacer: { width: 40, }, - commentsContainer: { - paddingVertical: 16, - marginHorizontal: -20, + sheetKeyboard: { + flex: 1, + }, + sheetBackground: { + backgroundColor: "#FFFFFF", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + }, + sheet: { + flex: 1, + backgroundColor: "#FFFFFF", + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + }, + sheetHandle: { + width: 44, + height: 5, + borderRadius: 999, + backgroundColor: "#D1D5DB", + }, + sheetTitle: { + fontSize: 16, + fontWeight: "700", + color: "#171717", + textAlign: "center", + marginTop: 8, + marginBottom: 8, + }, + sheetContent: { + flex: 1, + }, + commentList: { + flex: 1, + }, + commentListContent: { + paddingBottom: 120, + }, + emptyState: { + flex: 1, + justifyContent: "center", + paddingHorizontal: 20, + }, + emptyText: { + fontSize: 14, + color: "#9CA3AF", + textAlign: "center", + }, + composerContainer: { + backgroundColor: "#FFFFFF", }, }); + diff --git a/src/components/feed/FeedDiaryDetailCard.tsx b/src/components/feed/FeedDiaryDetailCard.tsx new file mode 100644 index 0000000..4a0588e --- /dev/null +++ b/src/components/feed/FeedDiaryDetailCard.tsx @@ -0,0 +1,166 @@ +import type { AxiosError } from "axios"; +import { useState } from "react"; +import { ActivityIndicator, Alert, StyleSheet, Text, View } from "react-native"; +import FeedDetail from "@/components/feed/FeedDetail"; +import usePostComment, { useDeleteComment } from "@/hooks/comments/useCommentApi"; +import useFeedLikeToggle from "@/hooks/feed/useFeedLikeToggle"; +import { useCreateReport } from "@/hooks/report/useReportApi"; +import { useDiaryDetail } from "@/hooks/log/useDiaryDetailApi"; +import type { FeedDetailResult } from "@/types/feed/detail"; + +type Props = { + postId: number; +}; + +export default function FeedDiaryDetailCard({ postId }: Props) { + const { data, isLoading, error, refetch } = useDiaryDetail(postId); + const [content, setContent] = useState(""); + const [isHidden, setIsHidden] = useState(false); + const { mutateAsync, isPending } = usePostComment(() => void refetch()); + const deleteCommentMutation = useDeleteComment(() => void refetch()); + const reportMutation = useCreateReport(); + const { liked, likeCount, toggleLike, isLikePending } = useFeedLikeToggle({ + targetId: postId, + targetType: "DIARY", + initialLiked: data?.isLiked ?? false, + initialLikeCount: data?.likeCount ?? 0, + onSuccessRefetch: refetch, + }); + + const handleSendComment = async () => { + if (!content.trim()) { + return; + } + + try { + await mutateAsync({ content, targetId: postId, targetType: "DIARY" }); + setContent(""); + } catch (commentError) { + console.error("[FeedDiaryDetailCard] Failed to post comment:", commentError); + } + }; + + const handleReportPost = async () => { + try { + await reportMutation.mutateAsync({ + targetType: "DIARY", + targetId: postId, + reason: "부적절한 콘텐츠", + additionalComment: "", + }); + setIsHidden(true); + Alert.alert("신고 완료", "신고가 접수되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("신고 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + const handleReportComment = async (commentId: number) => { + try { + await reportMutation.mutateAsync({ + targetType: "COMMENT", + targetId: commentId, + reason: "부적절한 댓글", + additionalComment: "", + }); + Alert.alert("신고 완료", "신고가 접수되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("신고 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + const handleDeleteComment = async (commentId: number) => { + try { + await deleteCommentMutation.mutateAsync({ commentId, targetType: "DIARY", targetId: postId }); + Alert.alert("삭제 완료", "댓글이 삭제되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("삭제 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + if (isLoading) { + return ( + + + 일기 상세를 불러오는 중입니다. + + ); + } + + if (error || !data) { + return ( + + 일기 상세를 불러오지 못했습니다. + + ); + } + + if (isHidden) { + return ( + + 신고한 일기입니다. + 이 항목은 나에게 숨김 처리되었습니다. + + ); + } + + const result: FeedDetailResult = { + id: data.id, + writerId: data.writerId, + writerName: data.writerName, + profileImageUrl: data.profileImageUrl, + content: data.content, + imageUrl: data.imageUrl, + isLiked: data.isLiked, + likeCount: data.likeCount, + commentCount: data.commentCount, + comments: data.comments, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + isPublic: data.isPublic, + }; + + return ( + void handleSendComment()} + isCommentPending={isPending} + onPressReport={handleReportPost} + onPressCommentReport={handleReportComment} + onPressCommentDelete={handleDeleteComment} + isReportPending={reportMutation.isPending || deleteCommentMutation.isPending} + /> + ); +} + +const styles = StyleSheet.create({ + statusCard: { + paddingHorizontal: 20, + paddingVertical: 32, + alignItems: "center", + gap: 12, + }, + statusText: { + fontSize: 14, + color: "#6B7280", + textAlign: "center", + }, + hiddenTitle: { + fontSize: 15, + fontWeight: "700", + color: "#171717", + }, +}); diff --git a/src/components/feed/FeedInfiniteDetailItem.tsx b/src/components/feed/FeedInfiniteDetailItem.tsx new file mode 100644 index 0000000..196e661 --- /dev/null +++ b/src/components/feed/FeedInfiniteDetailItem.tsx @@ -0,0 +1,49 @@ +import FeedAvatarDetailCard from "@/components/feed/FeedAvatarDetailCard"; +import FeedDiaryDetailCard from "@/components/feed/FeedDiaryDetailCard"; +import FeedSeedDetailCard from "@/components/feed/FeedSeedDetailCard"; +import type { FeedDetailResult } from "@/types/feed/detail"; +import type { RandomFeedPostType } from "@/types/feed/randomFeedApi.type"; + +type Props = { + postId: number; + postType: RandomFeedPostType; + isSeed: boolean; + seedResult?: FeedDetailResult; + onSeedRefetch?: () => void | Promise; + commentValue?: string; + onChangeComment?: (value: string) => void; + onSubmitComment?: () => void; + isCommentPending?: boolean; +}; + +export default function FeedInfiniteDetailItem({ + postId, + postType, + isSeed, + seedResult, + onSeedRefetch, + commentValue = "", + onChangeComment, + onSubmitComment, + isCommentPending = false, +}: Props) { + if (isSeed && seedResult && onSeedRefetch && onChangeComment && onSubmitComment) { + return ( + + ); + } + + return postType === "DIARY" ? ( + + ) : ( + + ); +} diff --git a/src/components/feed/FeedList.tsx b/src/components/feed/FeedList.tsx index 068d019..e500a11 100644 --- a/src/components/feed/FeedList.tsx +++ b/src/components/feed/FeedList.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React from "react"; import { View, Text, @@ -50,6 +50,17 @@ export default function FeedList({ ); } + if (!feedData.result.length) { + return ( + + 아직 올라온 게시글이 없습니다. + + 피드 데이터가 준비되면 이 화면에서 바로 상세로 이동할 수 있습니다. + + + ); + } + const renderItem = ({ item }: { item: FeedPost }) => ( ); @@ -92,6 +103,18 @@ const styles = StyleSheet.create({ fontSize: 14, color: "#EF4444", }, + emptyTitle: { + fontSize: 15, + fontWeight: "600", + color: "#171717", + }, + emptyDescription: { + marginTop: 4, + fontSize: 13, + lineHeight: 18, + color: "#6B7280", + textAlign: "center", + }, listContainer: { flexGrow: 1, }, @@ -99,9 +122,12 @@ const styles = StyleSheet.create({ width: ITEM_SIZE, height: ITEM_SIZE, backgroundColor: "#F3F4F6", + padding: 6, }, image: { width: "100%", height: "100%", + borderRadius: 8, }, }); + diff --git a/src/components/feed/FeedSeedDetailCard.tsx b/src/components/feed/FeedSeedDetailCard.tsx new file mode 100644 index 0000000..b974728 --- /dev/null +++ b/src/components/feed/FeedSeedDetailCard.tsx @@ -0,0 +1,138 @@ +import type { AxiosError } from "axios"; +import { Alert, StyleSheet, Text, View } from "react-native"; +import { useState } from "react"; +import FeedDetail from "@/components/feed/FeedDetail"; +import useFeedLikeToggle from "@/hooks/feed/useFeedLikeToggle"; +import { useDeleteComment } from "@/hooks/comments/useCommentApi"; +import { useCreateReport } from "@/hooks/report/useReportApi"; +import type { FeedDetailResult } from "@/types/feed/detail"; +import type { RandomFeedPostType } from "@/types/feed/randomFeedApi.type"; + +type Props = { + result: FeedDetailResult; + postType: RandomFeedPostType; + onRefetch: () => void | Promise; + commentValue: string; + onChangeComment: (value: string) => void; + onSubmitComment: () => void; + isCommentPending: boolean; +}; + +const getReportTargetLabel = (postType: RandomFeedPostType) => + postType === "DIARY" ? "일기" : "게시물"; + +export default function FeedSeedDetailCard({ + result, + postType, + onRefetch, + commentValue, + onChangeComment, + onSubmitComment, + isCommentPending, +}: Props) { + const reportMutation = useCreateReport(); + const deleteCommentMutation = useDeleteComment(() => void onRefetch()); + const [isHidden, setIsHidden] = useState(false); + const { liked, likeCount, toggleLike, isLikePending } = useFeedLikeToggle({ + targetId: result.id, + targetType: postType, + initialLiked: result.isLiked, + initialLikeCount: result.likeCount, + onSuccessRefetch: onRefetch, + }); + + const handleReportPost = async () => { + try { + await reportMutation.mutateAsync({ + targetType: postType, + targetId: result.id, + reason: "부적절한 콘텐츠", + additionalComment: "", + }); + setIsHidden(true); + Alert.alert("신고 완료", "신고가 접수되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("신고 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + const handleReportComment = async (commentId: number) => { + try { + await reportMutation.mutateAsync({ + targetType: "COMMENT", + targetId: commentId, + reason: "부적절한 댓글", + additionalComment: "", + }); + Alert.alert("신고 완료", "신고가 접수되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("신고 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + const handleDeleteComment = async (commentId: number) => { + try { + await deleteCommentMutation.mutateAsync({ commentId, targetType: postType, targetId: result.id }); + Alert.alert("삭제 완료", "댓글이 삭제되었습니다."); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + Alert.alert("삭제 실패", axiosError.response?.data?.message ?? "잠시 후 다시 시도해주세요."); + throw error; + } + }; + + if (isHidden) { + return ( + + 신고한 {getReportTargetLabel(postType)}입니다. + 이 항목은 나에게 숨김 처리되었습니다. + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#FFFFFF", + }, + hiddenCard: { + paddingHorizontal: 20, + paddingVertical: 32, + backgroundColor: "#FFFFFF", + gap: 6, + }, + hiddenTitle: { + fontSize: 15, + fontWeight: "700", + color: "#171717", + }, + hiddenDescription: { + fontSize: 13, + color: "#6B7280", + }, +}); diff --git a/src/components/follow/UserCard.tsx b/src/components/follow/UserCard.tsx new file mode 100644 index 0000000..a2e381f --- /dev/null +++ b/src/components/follow/UserCard.tsx @@ -0,0 +1,110 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import type { User } from "@/types/follow"; + +type Props = { + user: User; + actionLabel?: string; + actionDisabled?: boolean; + onPress?: () => void; + onActionPress?: () => void; +}; + +export default function UserCard({ + user, + actionLabel, + actionDisabled = false, + onPress, + onActionPress, +}: Props) { + return ( + + + + {user.userImageUrl ? ( + + ) : null} + + {user.username} + + + {actionLabel ? ( + + + {actionLabel} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: "#F3F4F6", + backgroundColor: "#FFFFFF", + }, + userInfo: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + avatarWrap: { + width: 40, + height: 40, + borderRadius: 20, + overflow: "hidden", + backgroundColor: "#E5E7EB", + }, + avatar: { + width: "100%", + height: "100%", + }, + username: { + fontSize: 15, + fontWeight: "500", + color: "#171717", + }, + actionButton: { + borderRadius: 999, + borderWidth: 1, + borderColor: "#D1D5DB", + paddingHorizontal: 12, + paddingVertical: 8, + }, + actionButtonDisabled: { + backgroundColor: "#F3F4F6", + }, + actionButtonText: { + fontSize: 13, + fontWeight: "600", + color: "#374151", + }, + actionButtonTextDisabled: { + color: "#9CA3AF", + }, +}); diff --git a/src/components/home/HomeAlertsModal.tsx b/src/components/home/HomeAlertsModal.tsx new file mode 100644 index 0000000..a138d42 --- /dev/null +++ b/src/components/home/HomeAlertsModal.tsx @@ -0,0 +1,368 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Image, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { useGuestbookList, useNotifications, useReadNotifications } from "@/hooks/home/useHomeApi"; +import type { GuestbookEntry, NotificationItem } from "@/types/home/alerts"; +import { createTimingLogger } from "@/utils/debug"; + +const birdImage = require("@/assets/images/bird.webp"); + +function isNotificationRead(item: NotificationItem) { + return item.isRead ?? item.read ?? false; +} + +type AlertTab = "GUESTBOOK" | "RECORD"; + +export default function HomeAlertsModal({ + visible, + userId, + onClose, +}: { + visible: boolean; + userId: number | null; + onClose: () => void; +}) { + const [activeTab, setActiveTab] = useState("GUESTBOOK"); + const [ + hasMarkedNotificationsAsReadThisOpen, + setHasMarkedNotificationsAsReadThisOpen, + ] = useState(false); + const openTimingRef = useRef | null>(null); + const notificationsQuery = useNotifications(visible); + const guestbookQuery = useGuestbookList(userId, visible); + const readNotificationsMutation = useReadNotifications(); + + const activeState = useMemo(() => { + if (activeTab === "GUESTBOOK") { + return { + isLoading: guestbookQuery.isLoading, + isError: guestbookQuery.isError, + items: guestbookQuery.data ?? [], + }; + } + + return { + isLoading: notificationsQuery.isLoading, + isError: notificationsQuery.isError, + items: notificationsQuery.data ?? [], + }; + }, [ + activeTab, + guestbookQuery.data, + guestbookQuery.isError, + guestbookQuery.isLoading, + notificationsQuery.data, + notificationsQuery.isError, + notificationsQuery.isLoading, + ]); + + useEffect(() => { + if (!visible) { + setHasMarkedNotificationsAsReadThisOpen(false); + openTimingRef.current = null; + return; + } + + if (hasMarkedNotificationsAsReadThisOpen) { + return; + } + + setHasMarkedNotificationsAsReadThisOpen(true); + void readNotificationsMutation.mutateAsync(); + }, [ + hasMarkedNotificationsAsReadThisOpen, + readNotificationsMutation, + visible, + ]); + + useEffect(() => { + if (!visible) { + return; + } + + openTimingRef.current = createTimingLogger("HomeAlertsModal", "modal open", { + activeTab, + }); + }, [activeTab, visible]); + + useEffect(() => { + if (!visible || !openTimingRef.current || activeState.isLoading) { + return; + } + + openTimingRef.current({ + activeTab, + itemCount: activeState.items.length, + isError: activeState.isError, + }); + openTimingRef.current = null; + }, [activeState.isError, activeState.isLoading, activeState.items.length, activeTab, visible]); + + return ( + + + + + + + + + + 받은 소식 + + 닫기 + + + + + setActiveTab("GUESTBOOK")} + /> + setActiveTab("RECORD")} + /> + + + + {activeState.isLoading ? ( + 불러오는 중입니다. + ) : activeState.isError ? ( + + {activeTab === "GUESTBOOK" + ? "받은 방명록을 불러오지 못했습니다." + : "알림을 불러오지 못했습니다."} + + ) : activeState.items.length === 0 ? ( + + {activeTab === "GUESTBOOK" + ? "아직 받은 방명록이 없어요." + : "아직 기록이 없어요."} + + ) : activeTab === "GUESTBOOK" ? ( + (activeState.items as GuestbookEntry[]).map((item, index) => ( + + + {item.author} + {formatDateTime(item.createdAt)} + + {item.content} + + )) + ) : ( + (activeState.items as NotificationItem[]).map(item => ( + + + {getNotificationLabel(item.notificationType)} + {formatDateTime(item.createdAt)} + + {item.content} + {!isNotificationRead(item) ? : null} + + )) + )} + + + + + + ); +} + +function TabButton({ + label, + active, + onPress, +}: { + label: string; + active: boolean; + onPress: () => void; +}) { + return ( + + {label} + + ); +} + +function formatDateTime(iso: string) { + const date = new Date(iso); + return `${date.getMonth() + 1}월 ${date.getDate()}일 ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function getNotificationLabel(type: string) { + switch (type) { + case "guestbook": + return "방명록"; + case "follow": + return "친구"; + case "watering_by_friend": + return "물주기"; + case "seed_delivery": + return "씨앗 배송"; + case "diary_like": + case "avatar_post_like": + case "feed_like": + return "좋아요"; + case "diary_comment": + case "avatar_post_comment": + case "feed_comment": + return "댓글"; + default: + return "알림"; + } +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.38)", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + cardFrame: { + width: "100%", + maxWidth: 360, + maxHeight: "80%", + position: "relative", + overflow: "visible", + }, + card: { + width: "100%", + maxHeight: "100%", + borderRadius: 28, + backgroundColor: "#FFFFFF", + paddingHorizontal: 20, + paddingTop: 22, + paddingBottom: 18, + overflow: "hidden", + }, + decorativeBird: { + position: "absolute", + top: -34, + left: 8, + width: 58, + height: 58, + zIndex: 3, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 12, + zIndex: 1, + }, + headerSpacer: { + width: 44, + }, + title: { + fontSize: 22, + fontWeight: "700", + color: "#171717", + }, + closeButton: { + minWidth: 44, + alignItems: "flex-end", + }, + closeText: { + fontSize: 14, + fontWeight: "600", + color: "#6B7280", + }, + tabRow: { + flexDirection: "row", + gap: 10, + marginBottom: 14, + zIndex: 1, + }, + tabButton: { + flex: 1, + borderRadius: 14, + paddingVertical: 12, + alignItems: "center", + backgroundColor: "#F3F4F6", + }, + tabButtonActive: { + backgroundColor: "#7DC960", + }, + tabButtonText: { + fontSize: 15, + fontWeight: "700", + color: "#6B7280", + }, + tabButtonTextActive: { + color: "#FFFFFF", + }, + content: { + flexGrow: 0, + }, + contentContainer: { + gap: 10, + paddingBottom: 6, + }, + messageText: { + paddingVertical: 36, + fontSize: 14, + lineHeight: 22, + color: "#9CA3AF", + textAlign: "center", + }, + listCard: { + borderRadius: 18, + backgroundColor: "#F8FAF6", + paddingHorizontal: 16, + paddingVertical: 14, + gap: 6, + position: "relative", + }, + listHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + primaryText: { + flex: 1, + fontSize: 14, + fontWeight: "700", + color: "#171717", + }, + metaText: { + fontSize: 12, + color: "#6B7280", + }, + secondaryText: { + fontSize: 14, + lineHeight: 20, + color: "#374151", + }, + unreadDot: { + position: "absolute", + top: 14, + right: 14, + width: 8, + height: 8, + borderRadius: 999, + backgroundColor: "#EF4444", + }, +}); diff --git a/src/components/home/HomeAvatarStage.tsx b/src/components/home/HomeAvatarStage.tsx new file mode 100644 index 0000000..25df044 --- /dev/null +++ b/src/components/home/HomeAvatarStage.tsx @@ -0,0 +1,225 @@ +import { Image, Pressable, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { useEffect, useState } from "react"; + +import { ANSWER_COPY } from "@/components/home/HomeEmotionModal"; +import type { SurveyAnswerKind } from "@/types/missions"; + +const wateringImage = require("@/assets/images/background/watering.png"); +const birdImage = require("@/assets/images/bird.webp"); +const characterImage = require("@/assets/images/char-stage.webp"); +const plantFallback = require("@/assets/images/plant.png"); + +export default function HomeAvatarStage({ + avatarImageUrl, + isWatering, + isEmotionAnswered, + answeredKind, + unreadNotificationCount = 0, + onPressEmotion, + onPressBird, +}: { + avatarImageUrl?: string | null; + isWatering: boolean; + isEmotionAnswered: boolean; + answeredKind: SurveyAnswerKind | null; + unreadNotificationCount?: number; + onPressEmotion: () => void; + onPressBird: () => void; +}) { + const [showAnsweredBubble, setShowAnsweredBubble] = useState(false); + const answerMessage = answeredKind ? ANSWER_COPY[answeredKind] : "좋은 기분으로 오늘 하루 계속 이어가요!"; + + useEffect(() => { + if (!isEmotionAnswered) { + setShowAnsweredBubble(false); + } + }, [isEmotionAnswered]); + + const handlePressMascot = () => { + if (isEmotionAnswered) { + setShowAnsweredBubble(prev => !prev); + return; + } + + onPressEmotion(); + }; + + return ( + + + + + + + {!isEmotionAnswered ? ( + + + + 오늘도 만나서 정말 반가워요!{"\n"}괜찮으시다면 오늘 하루는 어떠셨는지{"\n"}살짝 알려주시겠어요? + + + 마음 건강 체크 + + + + + ) : showAnsweredBubble ? ( + + + {answerMessage} + + + + ) : null} + + + + + + + + {unreadNotificationCount > 0 ? ( + + + {unreadNotificationCount > 99 ? "99+" : unreadNotificationCount} + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + stage: { + width: "100%", + alignItems: "center", + justifyContent: "center", + }, + avatarCluster: { + width: "100%", + minHeight: 400, + alignItems: "center", + justifyContent: "center", + position: "relative", + paddingBottom: 18, + }, + avatarImage: { + width: 320, + height: 320, + marginBottom: -96, + }, + wateringImage: { + position: "absolute", + left: "18%", + bottom: 168, + width: 118, + height: 118, + }, + wateringImageHidden: { + opacity: 0, + }, + leftCompanion: { + position: "absolute", + left: 26, + bottom: 4, + alignItems: "center", + width: 154, + }, + balloonWrap: { + alignItems: "center", + marginBottom: 2, + }, + balloon: { + borderRadius: 24, + backgroundColor: "#FFFFFF", + paddingHorizontal: 20, + paddingVertical: 16, + alignItems: "center", + width: 210, + }, + answeredBalloon: { + borderRadius: 24, + backgroundColor: "rgba(255,255,255,0.96)", + paddingHorizontal: 18, + paddingVertical: 14, + width: 200, + }, + balloonTail: { + width: 18, + height: 18, + backgroundColor: "#FFFFFF", + transform: [{ rotate: "45deg" }], + marginTop: -9, + marginLeft: -60, + }, + balloonText: { + fontSize: 13, + lineHeight: 19, + color: "#171717", + textAlign: "center", + }, + answeredBalloonText: { + fontSize: 13, + lineHeight: 18, + color: "#171717", + textAlign: "center", + }, + checkButton: { + marginTop: 12, + borderRadius: 14, + backgroundColor: "#7DC960", + paddingHorizontal: 16, + paddingVertical: 9, + }, + checkButtonText: { + color: "#FFFFFF", + fontSize: 13, + fontWeight: "700", + }, + mascotButton: { + marginTop: -10, + }, + characterImage: { + width: 120, + height: 120, + }, + birdButton: { + position: "absolute", + right: 56, + bottom: 0, + }, + notificationBadge: { + position: "absolute", + right: 2, + top: -4, + minWidth: 22, + height: 22, + borderRadius: 999, + paddingHorizontal: 6, + backgroundColor: "#EF4444", + alignItems: "center", + justifyContent: "center", + zIndex: 2, + }, + notificationBadgeText: { + fontSize: 11, + fontWeight: "700", + color: "#FFFFFF", + }, + birdImage: { + width: 84, + height: 84, + }, +}); diff --git a/src/components/home/HomeBottomSheet.tsx b/src/components/home/HomeBottomSheet.tsx new file mode 100644 index 0000000..6e462d2 --- /dev/null +++ b/src/components/home/HomeBottomSheet.tsx @@ -0,0 +1,423 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetHandleProps, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import CheckIcon from "@/assets/icons/Check.svg"; +import Check2Icon from "@/assets/icons/Check2.svg"; +import PlantBadgeIcon from "@/assets/icons/bottom-sheet/plant.svg"; +import WishTreeInfoModal from "@/components/home/WishTreeInfoModal"; +import { getMissionCompleted, type TodayMission } from "@/types/home/garden"; +import type { HomePanelPayload } from "@/types/home/panel"; + +const COLLAPSED_HEIGHT = 90; +const EXPANDED_HEIGHT = 580; + +export default function HomeBottomSheet({ + expanded, + onExpandedChange, + missions, + panel, + currentLevel, + onPressMission, + onPressEmotionCheck, +}: { + expanded: boolean; + onExpandedChange: (next: boolean) => void; + missions: TodayMission[]; + panel?: HomePanelPayload; + currentLevel: number; + onPressMission: (mission: TodayMission) => void; + onPressEmotionCheck: () => void; +}) { + const bottomSheetRef = useRef(null); + const [isWishInfoVisible, setIsWishInfoVisible] = useState(false); + const snapPoints = useMemo(() => [COLLAPSED_HEIGHT, EXPANDED_HEIGHT], []); + + useEffect(() => { + bottomSheetRef.current?.snapToIndex(expanded ? 1 : 0); + }, [expanded]); + + const checkingMission = missions.find(mission => mission.missionType === "CHECKING"); + const diaryMission = missions.find(mission => mission.missionType === "DIARY"); + const quizMission = missions.find(mission => mission.missionType === "QUIZ"); + const progressPercent = panel?.wishTree.progressPercent ?? 0; + const currentStage = panel?.wishTree.currentStage ?? `LV.${currentLevel}`; + const nextStage = panel?.wishTree.nextStage ?? `LV.${currentLevel + 1}`; + const currentGrowthPoints = panel?.wishTree.currentPoints ?? 0; + const requiredGrowthPoints = panel?.wishTree.requiredPointsForNextStage ?? 0; + const remainingGrowthPoints = Math.max(requiredGrowthPoints - currentGrowthPoints, 0); + + const missionCards: Array<{ + key: string; + label: string; + checked: boolean; + disabled?: boolean; + onPress: () => void; + }> = [ + { + key: "checking", + label: "마음 건강 체크", + checked: panel?.isCheckingCompleted ?? (checkingMission ? getMissionCompleted(checkingMission) : false), + disabled: panel?.isCheckingCompleted ?? (checkingMission ? getMissionCompleted(checkingMission) : false), + onPress: onPressEmotionCheck, + }, + { + key: "diary", + label: "일기 쓰기", + checked: panel?.isDairyCompleted ?? (diaryMission ? getMissionCompleted(diaryMission) : false), + onPress: () => diaryMission && onPressMission(diaryMission), + }, + { + key: "quiz", + label: "퀴즈 풀기", + checked: panel?.isQuizCompleted ?? (quizMission ? getMissionCompleted(quizMission) : false), + onPress: () => quizMission && onPressMission(quizMission), + }, + ]; + + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [] + ); + + const handleSheetChange = useCallback( + (index: number) => { + const nextExpanded = index > 0; + if (nextExpanded !== expanded) { + onExpandedChange(nextExpanded); + } + }, + [expanded, onExpandedChange] + ); + + const renderHandle = useCallback( + (_props: BottomSheetHandleProps) => ( + + + + + + 오늘의 미션 + + {missionCards.map(card => ( + + ))} + + + + ), + [missionCards] + ); + + return ( + + + + + {missionCards.map(card => ( + + + {card.label} + + + + ))} + + + + + + + + + + 소망 나무 + + setIsWishInfoVisible(true)} + style={styles.wishInfoButton} + > + ? + + + + {progressPercent >= 100 + ? "지금 바로 새로운 텃밭을 열 수 있어요!" + : `소망 나무 다음 성장까지 ${100 - progressPercent}%가 남았어요!`} + + + + + {currentStage} + {nextStage} + + + + + + + 현재 성장 + + {currentGrowthPoints} + / {requiredGrowthPoints} + + + + 다음 성장까지 + + {remainingGrowthPoints} + 남음 + + + + + + setIsWishInfoVisible(false)} /> + + ); +} + +function MissionStatusDot({ checked }: { checked: boolean }) { + return ( + + {checked ? : } + + ); +} + +const styles = StyleSheet.create({ + sheetOuter: { + ...StyleSheet.absoluteFillObject, + justifyContent: "flex-end", + }, + sheetBackground: { + backgroundColor: "#FFFFFF", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + borderTopWidth: 1, + borderTopColor: "#E5E7EB", + shadowColor: "#000000", + shadowOpacity: 0.12, + shadowRadius: 10, + shadowOffset: { width: 0, height: -4 }, + elevation: 12, + }, + handleWrap: { + paddingHorizontal: 20, + paddingTop: 14, + paddingBottom: 20, + backgroundColor: "#FFFFFF", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + sheetHandleButton: { + alignItems: "center", + paddingTop: 4, + paddingBottom: 14, + }, + sheetHandle: { + width: 44, + height: 5, + borderRadius: 999, + backgroundColor: "#B8C0CC", + }, + sheetHeaderRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + sheetTitle: { + fontSize: 18, + fontWeight: "700", + color: "#171717", + }, + sheetChecks: { + flexDirection: "row", + gap: 8, + }, + statusDot: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, + sheetContent: { + backgroundColor: "#FFFFFF", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + }, + sheetContentContainer: { + paddingHorizontal: 20, + paddingBottom: 30, + }, + sheetMissionList: { + marginTop: 8, + gap: 10, + }, + sheetMissionCard: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + borderRadius: 12, + borderWidth: 1, + borderColor: "#E5E7EB", + backgroundColor: "#FFFFFF", + paddingHorizontal: 16, + paddingVertical: 16, + }, + sheetMissionCardDone: { + borderColor: "transparent", + backgroundColor: "#EEF7E8", + }, + sheetMissionLabel: { + flex: 1, + fontSize: 15, + color: "#6B7280", + }, + sheetMissionLabelDone: { + color: "#2E5134", + fontWeight: "700", + }, + sheetDivider: { + marginVertical: 20, + borderTopWidth: 1, + borderTopColor: "#E5E7EB", + }, + wishHeader: { + gap: 6, + }, + wishTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + wishTreeBadge: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: "#EEF7E8", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + wishTitle: { + fontSize: 18, + fontWeight: "700", + color: "#171717", + }, + wishInfoButton: { + width: 26, + height: 26, + borderRadius: 13, + borderWidth: 1.5, + borderColor: "#9CA3AF", + alignItems: "center", + justifyContent: "center", + }, + wishInfoIcon: { + fontSize: 13, + fontWeight: "700", + color: "#9CA3AF", + lineHeight: 16, + }, + wishBody: { + fontSize: 14, + lineHeight: 20, + color: "#171717", + }, + wishStageRow: { + marginTop: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + wishCurrent: { + fontSize: 13, + color: "#59A647", + fontWeight: "700", + }, + wishNext: { + fontSize: 13, + color: "#171717", + fontWeight: "600", + }, + progressTrack: { + marginTop: 10, + height: 12, + borderRadius: 999, + backgroundColor: "#E7EEE1", + overflow: "hidden", + borderWidth: 1, + borderColor: "#D6E4CD", + }, + progressFill: { + height: "100%", + borderRadius: 999, + backgroundColor: "#6FBE57", + }, + progressMetaRow: { + marginTop: 12, + flexDirection: "row", + gap: 8, + }, + progressMetaCard: { + flex: 1, + borderRadius: 12, + backgroundColor: "#F5F9F0", + paddingHorizontal: 12, + paddingVertical: 10, + gap: 4, + }, + progressMetaLabel: { + fontSize: 12, + color: "#6B7280", + fontWeight: "600", + }, + progressMetaValue: { + fontSize: 16, + color: "#2E5134", + fontWeight: "700", + }, + progressMetaUnit: { + fontSize: 13, + color: "#6B7280", + fontWeight: "600", + }, +}); + + + diff --git a/src/components/home/HomeEmotionModal.tsx b/src/components/home/HomeEmotionModal.tsx new file mode 100644 index 0000000..0a7f4d3 --- /dev/null +++ b/src/components/home/HomeEmotionModal.tsx @@ -0,0 +1,224 @@ +import { useMemo, useState } from "react"; +import type { AxiosError } from "axios"; +import { + Image, + Modal, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { useAnswerDailySurvey, useDailySurvey } from "@/hooks/mission/useMissionApi"; +import type { ErrorResponse } from "@/types/common/apiResponse.type"; +import { + SURVEY_ANSWER_VALUE_MAP, + type SurveyAnswerKind, +} from "@/types/missions"; +import { createTimingLogger, debugLog } from "@/utils/debug"; + +const characterImage = require("@/assets/images/char-emotion.webp"); + +const ANSWER_COPY: Record = { + YES: "좋은 기분으로 오늘 하루 계속 이어가요!", + NEUTRAL: "제가 푸른 활력을 선물해 드릴게요.", + NO: "기운을 내볼까요? 제가 곁에 있을게요.", +}; + +export default function HomeEmotionModal({ + visible, + onClose, + onAnswered, +}: { + visible: boolean; + onClose: () => void; + onAnswered: (answer: SurveyAnswerKind) => void; +}) { + const { data, isLoading } = useDailySurvey(); + const answerMutation = useAnswerDailySurvey(); + const [submitErrorMessage, setSubmitErrorMessage] = useState(null); + + const answerOptions = useMemo( + () => + [ + { kind: "YES", label: "그럼요" }, + { kind: "NEUTRAL", label: "글쎄요" }, + { kind: "NO", label: "아니요" }, + ] satisfies Array<{ kind: SurveyAnswerKind; label: string }>, + [] + ); + + const handleAnswer = async (answer: SurveyAnswerKind) => { + if (!data?.id || answerMutation.isPending) { + return; + } + + if (data.isAnswered) { + onAnswered(answer); + onClose(); + return; + } + + const finishSubmitTiming = createTimingLogger("HomeEmotionModal", "answer submit", { + answer, + questionId: data.id, + }); + + setSubmitErrorMessage(null); + + try { + await answerMutation.mutateAsync({ + questionId: data.id, + answer: SURVEY_ANSWER_VALUE_MAP[answer], + }); + + onAnswered(answer); + onClose(); + finishSubmitTiming({ closedImmediately: true }); + } catch (error) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + const serverMessage = axiosError.response?.data?.message; + + if (status === 409) { + onAnswered(answer); + onClose(); + finishSubmitTiming({ closedImmediately: true, treatedAsAlreadyAnswered: true }); + return; + } + + finishSubmitTiming({ + closedImmediately: false, + status: status ?? null, + }); + debugLog("HomeEmotionModal", "answer submit failed", { + status: status ?? null, + serverMessage: serverMessage ?? null, + }); + setSubmitErrorMessage(serverMessage ?? "답변 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + }; + + const isAnswered = data?.isAnswered ?? false; + + return ( + + + + + 마음 건강 체크 + + + + {isLoading + ? "오늘의 질문을 불러오는 중입니다." + : isAnswered + ? "오늘의 질문에 이미 답변했어요.\n홈에서 완료 상태를 확인해보세요." + : (data?.question ?? "오늘 하루는 어떠셨는지 살짝 알려주시겠어요?")} + + + + {isAnswered ? ( + + 좋아요 + + ) : ( + + {answerOptions.map(option => ( + void handleAnswer(option.kind)} + disabled={answerMutation.isPending} + > + {option.label} + + ))} + + )} + + {submitErrorMessage ? ( + {submitErrorMessage} + ) : null} + + + + ); +} + +export { ANSWER_COPY }; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.38)", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + card: { + width: "100%", + maxWidth: 340, + borderRadius: 28, + backgroundColor: "#FFFFFF", + paddingHorizontal: 24, + paddingTop: 28, + paddingBottom: 24, + alignItems: "center", + gap: 20, + }, + title: { + fontSize: 22, + fontWeight: "700", + color: "#171717", + }, + bodyBlock: { + alignItems: "center", + gap: 12, + }, + characterImage: { + width: 82, + height: 82, + }, + body: { + fontSize: 15, + lineHeight: 23, + color: "#171717", + textAlign: "center", + }, + answerList: { + width: "100%", + gap: 10, + }, + primaryButton: { + width: "100%", + borderRadius: 16, + backgroundColor: "#7DC960", + paddingVertical: 14, + alignItems: "center", + }, + primaryButtonText: { + color: "#FFFFFF", + fontSize: 15, + fontWeight: "700", + }, + secondaryButton: { + width: "100%", + borderRadius: 16, + backgroundColor: "#EEF3EA", + paddingVertical: 14, + alignItems: "center", + }, + secondaryButtonText: { + color: "#2E5134", + fontSize: 15, + fontWeight: "700", + }, + errorText: { + width: "100%", + fontSize: 13, + lineHeight: 18, + color: "#B91C1C", + textAlign: "center", + }, +}); diff --git a/src/components/home/HomeGardenScene.tsx b/src/components/home/HomeGardenScene.tsx new file mode 100644 index 0000000..0773c8f --- /dev/null +++ b/src/components/home/HomeGardenScene.tsx @@ -0,0 +1,482 @@ +import { useEffect, useState } from "react"; +import { + Image, + ImageBackground, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import type { AxiosError } from "axios"; +import HomeAvatarStage from "@/components/home/HomeAvatarStage"; +import HomeToast from "@/components/home/HomeToast"; +import { useGardenSunlightAction, useGardenWaterAction } from "@/hooks/home/useHomeApi"; +import { + getGardenLocked, + getGardenUnlockable, + type GardenSummary, +} from "@/types/home/garden"; +import type { SurveyAnswerKind } from "@/types/missions"; +import SunIcon from "@/assets/icons/sun.svg"; +import WaterIcon from "@/assets/icons/water.svg"; +import LockIcon from "@/assets/icons/lock.svg"; +import UnlockedIcon from "@/assets/icons/unlocked.svg"; +import { createTimingLogger, debugLog } from "@/utils/debug"; + +const mapIcon = require("@/assets/images/map.png"); +const emptyGardenImage = require("@/assets/images/null.webp"); +const sunlightOverlay = require("@/assets/images/background/sunlight.png"); + +const SCENE_BOTTOM_OFFSET = 56; +const EMPTY_SCENE_BOTTOM_OFFSET = 124; +const ACTION_RAIL_BOTTOM_OFFSET = 128; +const WATER_ACTION_COOLDOWN_MS = 700; + +type Props = { + background: any; + slotNumber: number; + userName?: string | null; + garden: GardenSummary | null; + isEmotionAnswered: boolean; + answeredKind: SurveyAnswerKind | null; + unreadNotificationCount?: number; + onPressMap: () => void; + onPressBird: () => void; + onPressEmotion: () => void; + onPressUnlock: () => void; + onPressEmpty: () => void; +}; + +export default function HomeGardenScene({ + background, + slotNumber, + userName, + garden, + isEmotionAnswered, + answeredKind, + unreadNotificationCount = 0, + onPressMap, + onPressBird, + onPressEmotion, + onPressUnlock, + onPressEmpty, +}: Props) { + const sunlightMutation = useGardenSunlightAction(); + const waterMutation = useGardenWaterAction(); + const [isSunlightVisible, setIsSunlightVisible] = useState(false); + const [isWateringVisible, setIsWateringVisible] = useState(false); + const [canSunlight, setCanSunlight] = useState(Boolean(garden?.ownerSunlightAble)); + const [canWater, setCanWater] = useState(Boolean(garden?.ownerWateringAble)); + const [isWaterCooldownActive, setIsWaterCooldownActive] = useState(false); + const [toastMessage, setToastMessage] = useState(null); + + useEffect(() => { + setCanSunlight(Boolean(garden?.ownerSunlightAble)); + setCanWater(Boolean(garden?.ownerWateringAble)); + }, [garden?.ownerSunlightAble, garden?.ownerWateringAble]); + + const isLocked = garden ? getGardenLocked(garden) : false; + const isUnlockable = garden ? getGardenUnlockable(garden) : false; + const hasAvatar = Boolean(garden?.avatar?.avatarImageUrl); + const isEmptySlot = !isLocked && !hasAvatar; + const title = hasAvatar ? garden?.avatar?.avatarName : `텃밭 ${slotNumber}`; + const gardenId = garden?.gardenId; + const LockStatusIcon = isUnlockable ? UnlockedIcon : LockIcon; + + const handleActionError = (action: "sunlight" | "water", error: unknown) => { + const axiosError = error as AxiosError<{ message?: string }>; + const status = axiosError.response?.status; + const serverMessage = axiosError.response?.data?.message; + + debugLog("HomeGardenScene", `${action} action failed`, { + gardenId, + slotNumber, + status: status ?? null, + serverMessage: serverMessage ?? null, + }); + setToastMessage(serverMessage ?? "홈 상호작용 처리에 실패했습니다."); + }; + + const handleSunlight = async () => { + if (!gardenId) return; + if (!canSunlight || sunlightMutation.isPending) { + if (!canSunlight) { + setToastMessage("햇빛 주기는 오전 6시에 초기화 됩니다"); + } + return; + } + + const finishActionTiming = createTimingLogger("HomeGardenScene", "sunlight action", { + gardenId, + slotNumber, + }); + + setCanSunlight(false); + setIsSunlightVisible(true); + setTimeout(() => setIsSunlightVisible(false), 1000); + + try { + await sunlightMutation.mutateAsync(gardenId); + finishActionTiming({ startedImmediately: true }); + } catch (error) { + setCanSunlight(true); + setIsSunlightVisible(false); + finishActionTiming({ + startedImmediately: true, + rolledBack: true, + }); + handleActionError("sunlight", error); + } + }; + + const handleWater = async () => { + if (!gardenId) return; + if (!canWater || waterMutation.isPending || isWaterCooldownActive) { + if (!canWater) { + setToastMessage("물 주기는 오전 12시에 초기화 됩니다"); + } + return; + } + + const finishActionTiming = createTimingLogger("HomeGardenScene", "water action", { + gardenId, + slotNumber, + }); + + setCanWater(false); + setIsWaterCooldownActive(true); + setIsWateringVisible(true); + setTimeout(() => setIsWateringVisible(false), 1000); + setTimeout(() => setIsWaterCooldownActive(false), WATER_ACTION_COOLDOWN_MS); + + try { + await waterMutation.mutateAsync(gardenId); + finishActionTiming({ startedImmediately: true }); + } catch (error) { + setCanWater(true); + setIsWateringVisible(false); + setIsWaterCooldownActive(false); + finishActionTiming({ + startedImmediately: true, + rolledBack: true, + }); + handleActionError("water", error); + } + }; + + return ( + + {isLocked ? : null} + + + + {isLocked ? : null} + + + + {isLocked ? ( + + ) : ( + + + + )} + {!isLocked ? ( + {title ?? `${userName ?? "나풀나풀"}의 정원`} + ) : ( + + )} + + + + {isLocked ? ( + + + + + {isUnlockable ? "지금 열 수 있어요!" : "해금되지 않았습니다"} + + + {isUnlockable + ? "아래 버튼을 눌러 씨앗을 배송받고,\n새로운 곳에서 식물을 키워보세요." + : "성장 나무가 성장한 뒤에\n새로운 식물을 키울 수 있어요."} + + + + + + {isUnlockable ? "씨앗 받고 해금하기!" : "아직 감자가 충분히 모이지 않았어요"} + + + + ) : ( + + {hasAvatar ? ( + + void handleSunlight()} + style={styles.actionButton} + disabled={sunlightMutation.isPending} + > + + + void handleWater()} + style={styles.actionButton} + disabled={waterMutation.isPending || isWaterCooldownActive} + > + + + + ) : null} + + {isEmptySlot ? ( + + + + 새로운 식물을{"\n"}심어볼까요? + + + + + + + + ) : ( + + )} + + )} + + + {toastMessage ? setToastMessage(null)} /> : null} + + ); +} + +const styles = StyleSheet.create({ + sceneBackground: { + flex: 1, + }, + lockedBackgroundBlur: { + ...StyleSheet.absoluteFillObject, + }, + sunlightOverlay: { + ...StyleSheet.absoluteFillObject, + opacity: 1, + }, + sunlightOverlayHidden: { + opacity: 0, + }, + sceneShade: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(8, 20, 10, 0.08)", + }, + lockedScreenFog: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(242, 246, 241, 0.58)", + }, + sceneContent: { + flex: 1, + justifyContent: "space-between", + }, + sceneHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingTop: 56, + zIndex: 2, + }, + mapButton: { + width: 48, + height: 48, + alignItems: "center", + justifyContent: "center", + }, + mapIcon: { + width: 48, + height: 48, + }, + sceneTitle: { + flex: 1, + textAlign: "center", + fontSize: 22, + fontWeight: "700", + color: "#FFFFFF", + textShadowColor: "rgba(0, 0, 0, 0.18)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 4, + }, + sceneTitleSpacer: { + flex: 1, + }, + sceneHeaderSpacer: { + width: 48, + height: 48, + }, + sceneBody: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 24, + paddingBottom: SCENE_BOTTOM_OFFSET, + }, + emptySceneBody: { + justifyContent: "flex-end", + paddingBottom: EMPTY_SCENE_BOTTOM_OFFSET, + }, + lockedSceneBody: { + flex: 1, + justifyContent: "space-between", + paddingHorizontal: 20, + paddingBottom: 0, + zIndex: 2, + }, + actionRail: { + position: "absolute", + right: 12, + bottom: ACTION_RAIL_BOTTOM_OFFSET, + zIndex: 3, + gap: 8, + }, + actionButton: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: "rgba(255,255,255,0.18)", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + lockedOverlay: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 28, + gap: 10, + }, + lockStatusIcon: { + marginBottom: 6, + }, + lockedHeading: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#F76868", + textAlign: "center", + }, + unlockHeading: { + color: "#45B01B", + }, + lockedBody: { + fontSize: 16, + lineHeight: 26, + color: "#171717", + textAlign: "center", + }, + lockedFooterButton: { + alignSelf: "stretch", + marginHorizontal: -20, + height: 72, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 20, + }, + lockedFooterButtonActive: { + backgroundColor: "#72D14E", + }, + lockedFooterButtonDisabled: { + backgroundColor: "#EFEFEF", + }, + lockedFooterButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + textAlign: "center", + }, + lockedFooterButtonTextActive: { + color: "#FFFFFF", + }, + lockedFooterButtonTextDisabled: { + color: "#BFBFBF", + }, + emptySlotWrap: { + width: "100%", + alignItems: "center", + justifyContent: "center", + }, + emptyBubbleWrap: { + alignItems: "center", + marginBottom: -64, + }, + emptyBubble: { + borderRadius: 24, + backgroundColor: "rgba(255,255,255,0.95)", + paddingHorizontal: 26, + paddingVertical: 16, + alignItems: "center", + }, + emptyBubbleTail: { + width: 18, + height: 18, + marginTop: -9, + backgroundColor: "rgba(255,255,255,0.95)", + transform: [{ rotate: "45deg" }], + borderBottomRightRadius: 4, + }, + emptyBubbleText: { + fontSize: 15, + lineHeight: 22, + color: "#171717", + textAlign: "center", + }, + emptyBubblePlus: { + marginTop: 6, + fontSize: 28, + lineHeight: 30, + color: "#7DC960", + fontWeight: "700", + }, + emptyGardenImage: { + width: 360, + height: 288, + }, +}); + + diff --git a/src/components/home/HomeMapModal.tsx b/src/components/home/HomeMapModal.tsx new file mode 100644 index 0000000..b80e42a --- /dev/null +++ b/src/components/home/HomeMapModal.tsx @@ -0,0 +1,73 @@ +import { + Image, + Modal, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; + +const mapImages = [ + require("@/assets/images/map/map1.png"), + require("@/assets/images/map/map2.png"), + require("@/assets/images/map/map3.png"), + require("@/assets/images/map/map4.png"), +] as const; + +export default function HomeMapModal({ + visible, + slotNumber, + onClose, +}: { + visible: boolean; + slotNumber: number; + onClose: () => void; +}) { + const imageSource = mapImages[Math.max(0, Math.min(mapImages.length - 1, slotNumber - 1))]; + + return ( + + + + + 텃밭 지도 + + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.38)", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + card: { + width: "100%", + maxWidth: 360, + borderRadius: 28, + backgroundColor: "#FFFFFF", + paddingHorizontal: 18, + paddingTop: 20, + paddingBottom: 18, + alignItems: "center", + gap: 12, + }, + title: { + fontSize: 18, + fontWeight: "700", + color: "#171717", + }, + mapImage: { + width: 320, + height: 320, + borderRadius: 18, + }, +}); diff --git a/src/components/home/HomeToast.tsx b/src/components/home/HomeToast.tsx new file mode 100644 index 0000000..eed6f2e --- /dev/null +++ b/src/components/home/HomeToast.tsx @@ -0,0 +1,47 @@ +import { useEffect } from "react"; +import { StyleSheet, Text, View } from "react-native"; + +export default function HomeToast({ + message, + onClose, +}: { + message: string; + onClose: () => void; +}) { + useEffect(() => { + const timer = setTimeout(onClose, 2200); + return () => clearTimeout(timer); + }, [onClose]); + + return ( + + + {message} + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + position: "absolute", + left: 0, + right: 0, + bottom: 120, + alignItems: "center", + zIndex: 90, + }, + toast: { + maxWidth: 300, + borderRadius: 18, + backgroundColor: "rgba(23, 23, 23, 0.88)", + paddingHorizontal: 18, + paddingVertical: 12, + }, + message: { + color: "#FFFFFF", + fontSize: 13, + lineHeight: 18, + textAlign: "center", + }, +}); diff --git a/src/components/home/HomeTrackingModal.tsx b/src/components/home/HomeTrackingModal.tsx new file mode 100644 index 0000000..1299a9b --- /dev/null +++ b/src/components/home/HomeTrackingModal.tsx @@ -0,0 +1,109 @@ +import { + Image, + Modal, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import type { TrackingPromptStatusPayload } from "@/types/home/tracking"; + +const trackingImage = require("@/assets/images/tracking.webp"); + +export default function HomeTrackingModal({ + visible, + report, + isConfirming, + onConfirm, +}: { + visible: boolean; + report: TrackingPromptStatusPayload | null; + isConfirming: boolean; + onConfirm: () => void; +}) { + if (!report) { + return null; + } + + return ( + + + + + 2주 리포트가 도착했어요 + + + 최근 14일 동안 {report.perfectDayCount}일을 완벽하게 돌봤어요.{"\n"} + {report.message} + + + + {isConfirming ? "확인 중..." : "리포트 확인했어요"} + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.38)", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + card: { + width: "100%", + maxWidth: 340, + borderRadius: 28, + backgroundColor: "#FFFFFF", + paddingHorizontal: 24, + paddingTop: 28, + paddingBottom: 24, + alignItems: "center", + gap: 18, + }, + title: { + fontSize: 22, + fontWeight: "700", + color: "#171717", + textAlign: "center", + }, + trackingImage: { + width: 200, + height: 150, + }, + body: { + fontSize: 15, + lineHeight: 23, + color: "#171717", + textAlign: "center", + }, + primaryButton: { + width: "100%", + borderRadius: 16, + backgroundColor: "#7DC960", + paddingVertical: 14, + alignItems: "center", + }, + primaryButtonDisabled: { + opacity: 0.6, + }, + primaryButtonText: { + color: "#FFFFFF", + fontSize: 15, + fontWeight: "700", + }, +}); diff --git a/src/components/home/WishTreeInfoModal.tsx b/src/components/home/WishTreeInfoModal.tsx new file mode 100644 index 0000000..4d01900 --- /dev/null +++ b/src/components/home/WishTreeInfoModal.tsx @@ -0,0 +1,176 @@ +import { + Image, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; + +const sproutImage = require("@/assets/images/sprout.png"); +const flowerImage = require("@/assets/images/flower.png"); +const fruitImage = require("@/assets/images/fruit.png"); +const treeImage = require("@/assets/images/tree.png"); + +function StageIcon({ label, source }: { label: string; source: number }) { + return ( + + + + + {label} + + ); +} + +function StageDash() { + return ; +} + +const DAILY_SOURCES = [ + "내 식물 물 주기", + "햇빛 주기", + "일일 미션 - 마음 건강 체크, 일기, 퀴즈", + "친구에게 물 주기", + "방명록 쓰기", +]; + +type Props = { + visible: boolean; + onClose: () => void; +}; + +export default function WishTreeInfoModal({ visible, onClose }: Props) { + return ( + + + {}}> + + {"소망 나무"} + + + + + + + + + + + + + + + + + + {"\uAC01 \uB2E8\uACC4\uC5D0 \uD544\uC694\uD55C \uC810\uC218\uB97C \uC5BB\uC73C\uBA74\n\uB2E4\uC74C \uB2E8\uACC4\uB85C \uB118\uC5B4\uAC00\uACE0 \uC0C8 \uD143\uBC2D\uC744 \uC5F4 \uC218 \uC788\uC5B4\uC694."} + + + {"일일 점수 획득처"} + {DAILY_SOURCES.map(source => ( + + {source} + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.35)", + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 24, + }, + card: { + width: "100%", + maxWidth: 380, + backgroundColor: "#FFFFFF", + borderRadius: 24, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 28, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 24, + }, + title: { + fontSize: 20, + fontWeight: "700", + color: "#171717", + }, + closeButton: { + width: 32, + height: 32, + alignItems: "center", + justifyContent: "center", + }, + closeIcon: { + fontSize: 18, + color: "#9CA3AF", + }, + body: { + gap: 0, + }, + stagesRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginBottom: 24, + }, + stageIconWrap: { + alignItems: "center", + gap: 8, + }, + stageIconFrame: { + width: 52, + height: 52, + alignItems: "center", + justifyContent: "center", + }, + stageIconImage: { + width: 52, + height: 52, + }, + stageLabel: { + fontSize: 13, + color: "#6B7280", + marginTop: 4, + }, + stageDash: { + width: 20, + height: 1.5, + backgroundColor: "#D1D5DB", + marginBottom: 18, + marginHorizontal: 2, + }, + description: { + fontSize: 14, + lineHeight: 22, + color: "#374151", + marginBottom: 20, + }, + sectionTitle: { + fontSize: 15, + fontWeight: "700", + color: "#171717", + marginBottom: 10, + }, + sourceItem: { + fontSize: 14, + lineHeight: 26, + color: "#6B7280", + }, +}); + + diff --git a/src/components/log/MyDiaryDetail.tsx b/src/components/log/MyDiaryDetail.tsx new file mode 100644 index 0000000..eadedf6 --- /dev/null +++ b/src/components/log/MyDiaryDetail.tsx @@ -0,0 +1,142 @@ +import { Image, StyleSheet, Text, View } from "react-native"; +import { ChatIcon, EditIcon, HeartIcon } from "@/assets/icons/CommonIcons"; +import Comment from "@/components/common/Comment"; +import type { GETDiaryDetailResponse } from "@/types/log/diaryDetailApi.type"; + +type Props = { + detail: GETDiaryDetailResponse; +}; + +export default function MyDiaryDetail({ detail }: Props) { + const formatDate = (iso: string) => { + const date = new Date(iso); + return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; + }; + + return ( + + {formatDate(detail.createdAt)} + {detail.title} + + {detail.imageUrl ? ( + + ) : null} + + {detail.content} + + + + + + 공감 {detail.likeCount} + + + + 댓글 {detail.commentCount} + + + + + 수정 기능 보류 + + + + + {detail.comments.length > 0 ? ( + detail.comments.map(comment => ( + + )) + ) : ( + 아직 작성된 댓글이 없습니다. + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingTop: 24, + paddingHorizontal: 20, + }, + date: { + fontSize: 13, + color: "#6B7280", + marginBottom: 8, + }, + title: { + fontSize: 24, + fontWeight: "700", + color: "#171717", + marginBottom: 20, + }, + image: { + width: "100%", + aspectRatio: 1, + borderRadius: 12, + marginBottom: 20, + backgroundColor: "#F3F4F6", + }, + content: { + fontSize: 15, + lineHeight: 24, + color: "#171717", + marginBottom: 24, + }, + actionBar: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: "#E5E7EB", + paddingVertical: 16, + paddingHorizontal: 20, + marginHorizontal: -20, + }, + actionItems: { + flexDirection: "row", + alignItems: "center", + gap: 16, + }, + actionItem: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + actionText: { + fontSize: 14, + color: "#171717", + }, + editItem: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + editText: { + fontSize: 12, + color: "#6B7280", + }, + commentSection: { + paddingVertical: 16, + marginHorizontal: -20, + }, + emptyText: { + fontSize: 14, + color: "#9CA3AF", + textAlign: "center", + paddingVertical: 24, + }, +}); diff --git a/src/components/onboarding/OnboardingCarousel.tsx b/src/components/onboarding/OnboardingCarousel.tsx new file mode 100644 index 0000000..e9a91f5 --- /dev/null +++ b/src/components/onboarding/OnboardingCarousel.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; +import { + Dimensions, + FlatList, + Image, + NativeScrollEvent, + NativeSyntheticEvent, + StyleSheet, + Text, + View, +} from "react-native"; +import { onboardingSlides } from "@/constants/onboardingSlides"; + +const { width } = Dimensions.get("window"); + +type Props = { + paginationBottom?: number; +}; + +export default function OnboardingCarousel({ paginationBottom = 80 }: Props) { + const [currentIndex, setCurrentIndex] = useState(0); + + const handleScroll = (event: NativeSyntheticEvent) => { + const contentOffsetX = event.nativeEvent.contentOffset.x; + const index = Math.round(contentOffsetX / width); + setCurrentIndex(index); + }; + + return ( + <> + item.id.toString()} + renderItem={({ item }) => ( + + + + {item.title} + {item.subtitle} + + + )} + /> + + + {onboardingSlides.map((slide, index) => ( + + ))} + + + ); +} + +const styles = StyleSheet.create({ + slide: { + width, + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 32, + gap: 48, + }, + image: { + width: 256, + height: 256, + }, + textContainer: { + alignItems: "center", + gap: 8, + }, + title: { + fontSize: 20, + fontWeight: "bold", + color: "#000000", + textAlign: "center", + }, + subtitle: { + fontSize: 14, + color: "#4B5563", + textAlign: "center", + }, + pagination: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 8, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, + dotActive: { + backgroundColor: "#4CAF50", + }, + dotInactive: { + backgroundColor: "#D1D5DB", + }, +}); diff --git a/src/components/profile/ProfileDetail.tsx b/src/components/profile/ProfileDetail.tsx new file mode 100644 index 0000000..c12e47b --- /dev/null +++ b/src/components/profile/ProfileDetail.tsx @@ -0,0 +1,119 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import type { GardenInfo } from "@/types/profile/profileApi.type"; + +type Props = { + garden: GardenInfo; + leftWaterCountForOthers: number; + onWater: () => void; + waterDisabled?: boolean; +}; + +export default function ProfileDetail({ + garden, + leftWaterCountForOthers, + onWater, + waterDisabled = false, +}: Props) { + const canWater = garden.isWateringAbleByMe && !waterDisabled; + + return ( + + 대표 정원 + + + + {garden.avatarInfo?.avatarImageUrl ? ( + + ) : null} + + + + {garden.avatarInfo?.avatarName ?? "등록된 식물이 없습니다."} + + 남은 친구 물주기 {leftWaterCountForOthers}회 + + + + + {garden.isWateringAbleByMe ? "친구 물주기" : "오늘은 이미 물을 주었습니다"} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + paddingVertical: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: "700", + color: "#171717", + marginBottom: 12, + }, + card: { + backgroundColor: "#F5F7F1", + borderRadius: 20, + padding: 16, + gap: 16, + }, + avatarRow: { + flexDirection: "row", + alignItems: "center", + gap: 14, + }, + imageWrap: { + width: 72, + height: 72, + borderRadius: 18, + backgroundColor: "#E5E7EB", + overflow: "hidden", + }, + avatarImage: { + width: "100%", + height: "100%", + }, + textWrap: { + flex: 1, + gap: 4, + }, + avatarName: { + fontSize: 18, + fontWeight: "700", + color: "#171717", + }, + metaText: { + fontSize: 13, + lineHeight: 18, + color: "#6B7280", + }, + waterButton: { + borderRadius: 14, + backgroundColor: "#4CAF50", + paddingVertical: 14, + alignItems: "center", + }, + waterButtonDisabled: { + backgroundColor: "#E5E7EB", + }, + waterButtonText: { + color: "#FFFFFF", + fontSize: 14, + fontWeight: "700", + }, + waterButtonTextDisabled: { + color: "#9CA3AF", + }, +}); diff --git a/src/components/profile/ProfileGardenScene.tsx b/src/components/profile/ProfileGardenScene.tsx new file mode 100644 index 0000000..064763d --- /dev/null +++ b/src/components/profile/ProfileGardenScene.tsx @@ -0,0 +1,245 @@ +import { Image, ImageBackground, StyleSheet, Text, TouchableOpacity, View } from "react-native"; + +import type { GardenInfo } from "@/types/profile/profileApi.type"; +import WaterIcon from "@/assets/icons/water.svg"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const mailboxImage = require("@/assets/images/profile/letterbox.png"); +const wateringImage = require("@/assets/images/background/watering.png"); +const dropImage = require("@/assets/images/profile/drop.png"); +const ACTION_RAIL_BOTTOM_OFFSET = 128; +const MAX_WATER_COUNT = 3; + +type Props = { + background: any; + garden: GardenInfo; + isMe: boolean; + leftWaterCountForOthers: number; + isWateringVisible: boolean; + onWater: () => void; + onPressGuestbook: () => void; + waterDisabled?: boolean; +}; + +export default function ProfileGardenScene({ + background, + garden, + isMe, + leftWaterCountForOthers, + isWateringVisible, + onWater, + onPressGuestbook, + waterDisabled = false, +}: Props) { + const insets = useSafeAreaInsets(); + const canWater = !isMe && garden.isWateringAbleByMe && !waterDisabled; + const topOverlayOffset = insets.top + 144; + + return ( + + + + {garden.avatarInfo?.avatarName ? ( + + {garden.avatarInfo.avatarName} + + ) : null} + + {!isMe ? ( + + + {leftWaterCountForOthers}/{MAX_WATER_COUNT} + + ) : null} + + + {garden.avatarInfo?.avatarImageUrl ? ( + <> + + + + ) : ( + + 정원 정보가 준비되지 않았습니다. + + 현재 프로필 API 기준으로 표시할 식물 이미지가 없습니다. + + + )} + + {!isMe ? ( + + {/* 한글 주석: + 타인 프로필의 우편함은 홈의 비둘기처럼 식물 우하단에 붙는 장식 요소로만 두고, + 실제 방명록 진입은 하단 버튼에서 처리한다. */} + + + ) : null} + + + {!isMe ? ( + + + + + + ) : null} + + {!isMe ? ( + + {/* 한글 주석: + 타인 프로필의 방명록 버튼은 별도 전체 화면 방명록 페이지로 이동시키고, + 목록 확인과 새 글 작성은 그 화면에서 처리한다. */} + + 방명록 작성 + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + sceneBackground: { + flex: 1, + }, + sceneShade: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(8, 20, 10, 0.08)", + }, + waterCountBadge: { + position: "absolute", + right: 16, + flexDirection: "row", + alignItems: "center", + gap: 4, + zIndex: 3, + }, + scenePlantName: { + position: "absolute", + left: 0, + right: 0, + textAlign: "center", + fontSize: 28, + fontWeight: "600", + color: "#FFFFFF", + textShadowColor: "rgba(0, 0, 0, 0.18)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 4, + zIndex: 3, + }, + waterDropIcon: { + width: 18, + height: 18, + }, + waterCountText: { + fontSize: 16, + fontWeight: "700", + color: "#FFFFFF", + textShadowColor: "rgba(0,0,0,0.18)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 4, + }, + sceneBody: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 24, + paddingTop: 340, + paddingBottom: -32, + position: "relative", + }, + avatarImage: { + width: 300, + height: 300, + }, + wateringImage: { + position: "absolute", + left: "24%", + bottom: 196, + width: 118, + height: 118, + }, + wateringImageHidden: { + opacity: 0, + }, + mailboxWrap: { + position: "absolute", + right: 48, + bottom: 18, + zIndex: 2, + }, + mailboxImage: { + width: 84, + height: 84, + }, + emptyAvatarBubble: { + borderRadius: 24, + backgroundColor: "rgba(255,255,255,0.92)", + paddingHorizontal: 24, + paddingVertical: 18, + alignItems: "center", + gap: 6, + }, + emptyAvatarTitle: { + fontSize: 16, + fontWeight: "700", + color: "#171717", + textAlign: "center", + }, + emptyAvatarBody: { + fontSize: 14, + lineHeight: 20, + color: "#4B5563", + textAlign: "center", + }, + actionRail: { + position: "absolute", + right: 12, + zIndex: 3, + gap: 8, + }, + actionButton: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: "#FFFFFF", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + actionButtonDisabled: { + backgroundColor: "rgba(255,255,255,0.5)", + }, + guestbookWrap: { + paddingHorizontal: 20, + paddingTop: 8, + }, + guestbookButton: { + height: 56, + borderRadius: 8, + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#7DC960", + alignItems: "center", + justifyContent: "center", + }, + guestbookButtonText: { + fontSize: 18, + fontWeight: "600", + color: "#3AB40B", + }, +}); diff --git a/src/components/registration/AvatarPreviewCard.tsx b/src/components/registration/AvatarPreviewCard.tsx new file mode 100644 index 0000000..576ec5d --- /dev/null +++ b/src/components/registration/AvatarPreviewCard.tsx @@ -0,0 +1,60 @@ +import { Image, StyleSheet, Text, View } from "react-native"; + +type Props = { + imageUrl?: string | null; + title: string; + description?: string; +}; + +export default function AvatarPreviewCard({ + imageUrl, + title, + description, +}: Props) { + return ( + + {imageUrl ? ( + + ) : ( + + {title} + + )} + {description ? {description} : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 20, + padding: 16, + backgroundColor: "#F2F8EE", + gap: 12, + alignItems: "center", + }, + image: { + width: 220, + height: 220, + borderRadius: 18, + backgroundColor: "#E5E7EB", + }, + placeholder: { + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 16, + }, + placeholderText: { + textAlign: "center", + fontSize: 18, + lineHeight: 26, + fontWeight: "700", + color: "#1F5C27", + }, + description: { + fontSize: 14, + lineHeight: 20, + color: "#4B5563", + textAlign: "center", + }, +}); diff --git a/src/components/registration/RegistrationFooter.tsx b/src/components/registration/RegistrationFooter.tsx new file mode 100644 index 0000000..f68beeb --- /dev/null +++ b/src/components/registration/RegistrationFooter.tsx @@ -0,0 +1,51 @@ +import { StyleSheet, Text, TouchableOpacity } from "react-native"; + +type Props = { + primaryLabel: string; + onPrimaryPress: () => void; + primaryDisabled?: boolean; + primaryLoading?: boolean; + secondaryLabel?: string; + onSecondaryPress?: () => void; +}; + +export default function RegistrationFooter({ + primaryLabel, + onPrimaryPress, + primaryDisabled = false, + primaryLoading = false, +}: Props) { + return ( + + + {primaryLoading ? "처리 중..." : primaryLabel} + + + ); +} + +const styles = StyleSheet.create({ + footer: { + height: 72, + backgroundColor: "#2F7D32", + alignItems: "center", + justifyContent: "center", + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: "#2F7D32", + }, + footerDisabled: { + backgroundColor: "#A7D4A5", + borderTopColor: "#A7D4A5", + }, + label: { + fontSize: 16, + fontWeight: "700", + color: "#FFFFFF", + letterSpacing: 0.3, + }, +}); diff --git a/src/components/registration/RegistrationModeCard.tsx b/src/components/registration/RegistrationModeCard.tsx new file mode 100644 index 0000000..a59f1be --- /dev/null +++ b/src/components/registration/RegistrationModeCard.tsx @@ -0,0 +1,81 @@ +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import type { RegistrationMode } from "@/types/avatars"; + +type Props = { + mode: RegistrationMode; + title: string; + description: string; + previewLabel: string; + selected: boolean; + onPress: () => void; +}; + +export default function RegistrationModeCard({ + title, + description, + previewLabel, + selected, + onPress, +}: Props) { + return ( + + + {title} + {description} + + + {previewLabel} + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 22, + padding: 18, + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#E5E7EB", + gap: 14, + }, + cardSelected: { + borderColor: "#2F7D32", + backgroundColor: "#F2FAF1", + }, + copy: { + gap: 6, + }, + title: { + fontSize: 20, + fontWeight: "700", + color: "#171717", + }, + description: { + fontSize: 14, + lineHeight: 20, + color: "#4B5563", + }, + preview: { + minHeight: 116, + borderRadius: 18, + backgroundColor: "#F3F4F6", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 16, + }, + previewSelected: { + backgroundColor: "#DDF3DE", + }, + previewText: { + fontSize: 16, + lineHeight: 24, + fontWeight: "700", + color: "#1F5C27", + textAlign: "center", + }, +}); diff --git a/src/components/registration/RegistrationTextField.tsx b/src/components/registration/RegistrationTextField.tsx new file mode 100644 index 0000000..9b11a1c --- /dev/null +++ b/src/components/registration/RegistrationTextField.tsx @@ -0,0 +1,80 @@ +import { StyleSheet, Text, TextInput, View } from "react-native"; + +type Props = { + label: string; + value: string; + onChangeText: (value: string) => void; + placeholder: string; + multiline?: boolean; + helperText?: string; +}; + +export default function RegistrationTextField({ + label, + value, + onChangeText, + placeholder, + multiline = false, + helperText, +}: Props) { + return ( + + {label ? {label} : null} + + {helperText ? {helperText} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 2, + }, + label: { + fontSize: 12, + fontWeight: "500", + color: "#9CA3AF", + letterSpacing: 0.2, + marginBottom: 2, + }, + input: { + fontSize: 15, + color: "#171717", + backgroundColor: "transparent", + paddingHorizontal: 0, + paddingVertical: 10, + }, + titleInput: { + fontSize: 26, + fontWeight: "400", + letterSpacing: -0.3, + paddingVertical: 6, + }, + singleLine: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#E0E0E0", + }, + multiline: { + minHeight: 80, + lineHeight: 24, + fontSize: 15, + color: "#374151", + }, + helperText: { + fontSize: 12, + lineHeight: 18, + color: "#6B7280", + }, +}); \ No newline at end of file diff --git a/src/components/registration/SelectionAvatarCard.tsx b/src/components/registration/SelectionAvatarCard.tsx new file mode 100644 index 0000000..e38d8ef --- /dev/null +++ b/src/components/registration/SelectionAvatarCard.tsx @@ -0,0 +1,76 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import type { AvatarMaster } from "@/types/avatars"; + +type Props = { + avatar: AvatarMaster; + selected: boolean; + onPress: () => void; +}; + +export default function SelectionAvatarCard({ + avatar, + selected, + onPress, +}: Props) { + return ( + + + {avatar.description} + + + {selected ? "선택됨" : "선택하기"} + + + + ); +} + +const styles = StyleSheet.create({ + card: { + width: "48%", + borderRadius: 18, + padding: 12, + borderWidth: 1, + borderColor: "#E5E7EB", + backgroundColor: "#FFFFFF", + gap: 10, + }, + cardSelected: { + borderColor: "#2F7D32", + backgroundColor: "#F2FAF1", + }, + image: { + width: "100%", + aspectRatio: 1, + borderRadius: 14, + backgroundColor: "#F3F4F6", + }, + description: { + fontSize: 14, + lineHeight: 20, + color: "#171717", + fontWeight: "600", + }, + badge: { + alignSelf: "flex-start", + borderRadius: 999, + backgroundColor: "#F3F4F6", + paddingHorizontal: 10, + paddingVertical: 6, + }, + badgeSelected: { + backgroundColor: "#DDF3DE", + }, + badgeText: { + fontSize: 12, + fontWeight: "700", + color: "#6B7280", + }, + badgeTextSelected: { + color: "#1F5C27", + }, +}); diff --git a/src/constants/onboardingSlides.ts b/src/constants/onboardingSlides.ts new file mode 100644 index 0000000..226cb68 --- /dev/null +++ b/src/constants/onboardingSlides.ts @@ -0,0 +1,28 @@ +export const onboardingSlides = [ + { + id: 1, + image: require("@/assets/images/onboarding/onboarding1.png"), + title: "나만의 식물을 화면 속에서 만나보세요", + subtitle: "물과 햇빛을 주며 직접 키울 수 있어요", + }, + { + id: 2, + image: require("@/assets/images/onboarding/onboarding2.png"), + title: "실제 식물을 배송받아 키워보세요", + subtitle: "식물 아바타에 해당하는 실제 식물을 키울 수 있어요", + }, + { + id: 3, + image: require("@/assets/images/onboarding/onboarding3.png"), + title: "미션을 통해 나무 레벨을 올리고\n새로운 식물을 키울 수 있어요", + subtitle: "미션 기록은 키움일지에 기록돼요", + }, + { + id: 4, + image: require("@/assets/images/onboarding/onboarding4.png"), + title: "다른 친구들의 이야기를 들을 수 있어요", + subtitle: "둘러보기로 친구를 만들고 방명록을 남겨보세요", + }, +] as const; + +export type OnboardingSlide = (typeof onboardingSlides)[number]; diff --git a/src/hooks/auth/useBackendLogin.ts b/src/hooks/auth/useBackendLogin.ts new file mode 100644 index 0000000..673a3d7 --- /dev/null +++ b/src/hooks/auth/useBackendLogin.ts @@ -0,0 +1,29 @@ +import { useMutation } from "@tanstack/react-query"; +import { loginWithSupabaseApi } from "@/apis/register/registerApi"; +import useTokenStore from "@/stores/useTokenStore"; +import { PostRegisterResponse } from "@/types/apis/register"; +import { GlobalResponse } from "@/types/common/apiResponse.type"; + +// [STEP 3.5] 커스텀 훅 - 백엔드 로그인 연동 로직 +// Supabase OAuth 로그인 후 획득한 토큰을 MainBE 백엔드에 보내고 +// 응답으로 받은 자체 JWT 및 UserId를 useTokenStore (Zustand/AsyncStorage)에 영구 저장합니다. +export const useBackendLogin = () => { + const { setAuth } = useTokenStore(); + + return useMutation, Error, string>({ + mutationFn: (accessToken: string) => loginWithSupabaseApi(accessToken), + onSuccess: (data) => { + // 요청이 성공했을 경우 토큰 정보를 상태 및 기기 저장소에 업데이트합니다. + if (data.isSuccess && data.result) { + setAuth({ + accessToken: data.result.accessToken, + refreshToken: data.result.refreshToken, + userId: String(data.result.userId), + }); + } + }, + onError: (error) => { + console.error("[useBackendLogin] Backend login failed:", error); + }, + }); +}; diff --git a/src/hooks/auth/useSupabaseOAuth.ts b/src/hooks/auth/useSupabaseOAuth.ts new file mode 100644 index 0000000..4598589 --- /dev/null +++ b/src/hooks/auth/useSupabaseOAuth.ts @@ -0,0 +1,231 @@ +import { useState } from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import * as WebBrowser from "expo-web-browser"; +import * as Linking from "expo-linking"; +import { supabase } from "@/apis/supabase"; +import { useBackendLogin } from "./useBackendLogin"; +import { debugLog } from "@/utils/debug"; + +WebBrowser.maybeCompleteAuthSession(); + +type OAuthProvider = "google" | "kakao"; + +type OAuthResult = { + success: boolean; + cancelled?: boolean; + error?: unknown; + isNewUser?: boolean; + requiresNicknameSetup?: boolean; + nickname?: string; +}; + +const REDIRECT_TIMEOUT_MS = 120000; +const DISMISS_REDIRECT_GRACE_MS = 15000; + +function extractSessionTokens(url: string) { + const urlObj = new URL(url); + const fragment = urlObj.hash.startsWith("#") ? urlObj.hash.substring(1) : urlObj.hash; + const fragmentParams = new URLSearchParams(fragment); + + let accessToken = fragmentParams.get("access_token"); + let refreshToken = fragmentParams.get("refresh_token"); + + if (!accessToken) { + accessToken = urlObj.searchParams.get("access_token"); + refreshToken = urlObj.searchParams.get("refresh_token"); + } + + return { + accessToken, + refreshToken, + }; +} + +function getOAuthRedirectUri() { + if (!Linking.hasCustomScheme()) { + return Linking.createURL("auth/callback"); + } + + return Linking.createURL("auth/callback", { + scheme: "haniumapp", + }); +} + +export const useSupabaseOAuth = () => { + const [isLoading, setIsLoading] = useState(false); + const { mutateAsync: backendLogin } = useBackendLogin(); + + const redirectUri = getOAuthRedirectUri(); + const isExpoGo = !Linking.hasCustomScheme(); + + const completeLogin = async (url: string): Promise => { + debugLog("SupabaseOAuth", "Callback received", { url }); + + const { accessToken, refreshToken } = extractSessionTokens(url); + + if (!accessToken) { + console.warn("[SupabaseOAuth] No access token found in callback URL.", { url }); + throw new Error("No access_token found in URL"); + } + + debugLog("SupabaseOAuth", "Tokens parsed", { + hasAccessToken: Boolean(accessToken), + hasRefreshToken: Boolean(refreshToken), + }); + debugLog("SupabaseOAuth", "Setting Supabase session"); + await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken || "", + }); + + debugLog("SupabaseOAuth", "Supabase session stored"); + debugLog("SupabaseOAuth", "Sending access token to backend"); + const backendRes = await backendLogin(accessToken); + + if (!backendRes.isSuccess) { + console.error("[SupabaseOAuth] Backend login failed with message:", backendRes.message); + throw new Error(backendRes.message); + } + + debugLog("SupabaseOAuth", "Backend login complete", { + isNewUser: backendRes.result?.newUser, + requiresNicknameSetup: backendRes.result?.requiresNicknameSetup, + nickname: backendRes.result?.nickname, + }); + return { + success: true, + isNewUser: backendRes.result?.newUser, + requiresNicknameSetup: backendRes.result?.requiresNicknameSetup, + nickname: backendRes.result?.nickname, + }; + }; + + const waitForRedirect = (expectedRedirectUri: string) => + new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + urlSubscription.remove(); + appStateSubscription.remove(); + clearTimeout(timeoutId); + }; + + const resolveIfMatches = async (candidateUrl: string | null | undefined, source: string) => { + if (settled || !candidateUrl) { + return; + } + + debugLog("SupabaseOAuth", "Redirect candidate detected", { + source, + url: candidateUrl, + }); + + if (!candidateUrl.startsWith(expectedRedirectUri)) { + debugLog("SupabaseOAuth", "Ignoring unrelated redirect URL", { + expectedRedirectUri, + actualUrl: candidateUrl, + source, + }); + return; + } + + settled = true; + cleanup(); + void WebBrowser.dismissBrowser(); + debugLog("SupabaseOAuth", "Redirect matched expected URI", { source }); + resolve(candidateUrl); + }; + + const urlSubscription = Linking.addEventListener("url", event => { + void resolveIfMatches(event.url, "url_event"); + }); + + const appStateSubscription = AppState.addEventListener("change", (state: AppStateStatus) => { + if (state !== "active" || settled) { + return; + } + + debugLog("SupabaseOAuth", "App became active while waiting for redirect"); + + void Linking.getInitialURL() + .then(url => resolveIfMatches(url, "app_active_initial_url")) + .catch(error => { + debugLog("SupabaseOAuth", "Failed to read initial URL on app resume", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + + void Linking.getInitialURL() + .then(url => resolveIfMatches(url, "initial_url")) + .catch(error => { + debugLog("SupabaseOAuth", "Failed to read initial URL before auth", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + const timeoutId = setTimeout(() => { + if (settled) { + return; + } + + settled = true; + cleanup(); + debugLog("SupabaseOAuth", "Redirect wait timed out", { expectedRedirectUri }); + reject(new Error("OAuth redirect timed out")); + }, REDIRECT_TIMEOUT_MS); + }); + + const performOAuth = async (provider: OAuthProvider): Promise => { + setIsLoading(true); + debugLog("SupabaseOAuth", "performOAuth started", { provider, redirectUri }); + + try { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: redirectUri, + skipBrowserRedirect: true, + }, + }); + + if (error) { + console.error("[SupabaseOAuth] signInWithOAuth error:", error); + throw error; + } + + if (!data?.url) { + console.error("[SupabaseOAuth] No URL returned from signInWithOAuth"); + throw new Error("No URL returned"); + } + + debugLog("SupabaseOAuth", "Opening auth browser", { url: data.url }); + + const redirectPromise = waitForRedirect(redirectUri); + + void WebBrowser.openBrowserAsync(data.url).then(result => { + debugLog("SupabaseOAuth", "Browser result received", { + type: result.type, + url: "url" in result ? result.url : undefined, + }); + }); + + const redirectedUrl = await redirectPromise; + debugLog("SupabaseOAuth", "Redirect event resolved", { url: redirectedUrl }); + const result = await completeLogin(redirectedUrl); + debugLog("SupabaseOAuth", "OAuth flow completed", result); + return result; + } catch (err) { + console.error("[SupabaseOAuth] Exception during performOAuth:", err); + debugLog("SupabaseOAuth", "OAuth flow failed", { + error: err instanceof Error ? err.message : String(err), + }); + return { success: false, error: err }; + } finally { + setIsLoading(false); + debugLog("SupabaseOAuth", "performOAuth finished"); + } + }; + + return { performOAuth, isLoading, redirectUri, isExpoGo }; +}; diff --git a/src/hooks/avatars/useAvatarApi.ts b/src/hooks/avatars/useAvatarApi.ts new file mode 100644 index 0000000..5ac5e45 --- /dev/null +++ b/src/hooks/avatars/useAvatarApi.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + getAvatarMastersApi, + postFinalChoiceAvatarApi, + postUploadCreationAvatarApi, +} from "@/apis/avatars/avatarApi"; +import type { + AvatarMaster, + FinalChoiceAvatarRequest, + FinalChoiceAvatarResponse, + SelectAvatarResponse, + UploadCreationAvatarResponse, +} from "@/types/avatars"; + +export const useAvatarMasters = () => + useQuery({ + queryKey: ["avatar-masters"], + queryFn: getAvatarMastersApi, + select: data => data.result, + }); + +export const useUploadCreationAvatar = () => + useMutation({ + mutationFn: formData => postUploadCreationAvatarApi(formData), + }); + +export const useFinalChoiceAvatar = () => + useMutation({ + mutationFn: payload => postFinalChoiceAvatarApi(payload), + }); diff --git a/src/hooks/block/useBlockApi.ts b/src/hooks/block/useBlockApi.ts new file mode 100644 index 0000000..d725d9c --- /dev/null +++ b/src/hooks/block/useBlockApi.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteBlockUser, postBlockUser } from "@/apis/block/blockApi"; + +export const useBlockUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (targetUserId: number) => postBlockUser(targetUserId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["feed"] }); + }, + }); +}; + +export const useUnblockUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (targetUserId: number) => deleteBlockUser(targetUserId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["feed"] }); + }, + }); +}; diff --git a/src/hooks/comments/useCommentApi.ts b/src/hooks/comments/useCommentApi.ts index 383522a..fa6e6a3 100644 --- a/src/hooks/comments/useCommentApi.ts +++ b/src/hooks/comments/useCommentApi.ts @@ -1,18 +1,114 @@ -import { useMutation } from "@tanstack/react-query"; - +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { GlobalResponse } from "@/types/common/apiResponse.type"; import type { PostCommentRequest, PostCommentResponse, } from "@/types/comments/commentApi.type"; +import type { GETDiaryDetailResponse } from "@/types/log/diaryDetailApi.type"; +import type { GETAvatarPostDetailResponse } from "@/types/feed/avatarPostDetailApi.type"; +import { deleteComment, postComment } from "@/apis/comments/commentApi"; +import useTokenStore from "@/stores/useTokenStore"; +import { useHomeSummaryStore } from "@/stores/useHomeSummaryStore"; + +export type DeleteCommentVariables = { + commentId: number; + targetType: "DIARY" | "AVATAR_POST"; + targetId: number; +}; + +type DetailCache = GlobalResponse; + +type CommentMutateContext = { + previous: DetailCache | undefined; + queryKey: (string | number)[]; +}; + +const getDetailQueryKey = (targetType: "DIARY" | "AVATAR_POST", targetId: number) => + targetType === "DIARY" + ? ["diary-detail", targetId] + : ["avatar-post-detail", targetId]; -import { postComment } from "@/apis/comments/commentApi"; +export const usePostComment = (onSuccessRefetch?: () => void) => { + const queryClient = useQueryClient(); + const { userId } = useTokenStore(); + const { user } = useHomeSummaryStore(); -export const usePostComment = (onSuccessRefetch?: () => void) => - useMutation<{ result: PostCommentResponse }, unknown, PostCommentRequest>({ + return useMutation<{ result: PostCommentResponse }, unknown, PostCommentRequest, CommentMutateContext>({ mutationFn: (body: PostCommentRequest) => postComment(body), - onSuccess: () => { + onMutate: async ({ content, targetType, targetId }) => { + const queryKey = getDetailQueryKey(targetType, targetId); + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + // 임시 음수 ID: 서버 응답 후 onSettled 리패치로 실제 ID로 교체됨 + const optimisticComment = { + commentId: -Date.now(), + writerId: Number(userId), + profileImageUrl: null, + writer: user?.username ?? "", + content, + }; + + queryClient.setQueryData(queryKey, old => + old + ? { + ...old, + result: { + ...old.result, + comments: [...old.result.comments, optimisticComment], + commentCount: old.result.commentCount + 1, + }, + } + : old + ); + + return { previous, queryKey }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(context.queryKey, context.previous); + } + }, + onSettled: () => { + onSuccessRefetch?.(); + }, + }); +}; + +export const useDeleteComment = (onSuccessRefetch?: () => void) => { + const queryClient = useQueryClient(); + + return useMutation<{ result: void }, unknown, DeleteCommentVariables, CommentMutateContext>({ + mutationFn: ({ commentId }) => deleteComment(commentId), + onMutate: async ({ commentId, targetType, targetId }) => { + const queryKey = getDetailQueryKey(targetType, targetId); + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, old => + old + ? { + ...old, + result: { + ...old.result, + comments: old.result.comments.filter(c => c.commentId !== commentId), + commentCount: Math.max(0, old.result.commentCount - 1), + }, + } + : old + ); + + return { previous, queryKey }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(context.queryKey, context.previous); + } + }, + onSettled: () => { onSuccessRefetch?.(); }, }); +}; -export default usePostComment; +export default usePostComment; \ No newline at end of file diff --git a/src/hooks/delivery/useDeliveryApi.ts b/src/hooks/delivery/useDeliveryApi.ts new file mode 100644 index 0000000..a277328 --- /dev/null +++ b/src/hooks/delivery/useDeliveryApi.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + getDeliverablePlants, + postSeedDelivery, + postUnlockGarden, +} from "@/apis/delivery/deliveryApi"; +import type { GlobalResponse } from "@/types/common/apiResponse.type"; +import type { CreateSeedDeliveryRequest, DeliverablePlant } from "@/types/delivery"; + +export const useDeliverablePlants = () => + useQuery< + GlobalResponse, + AxiosError, + DeliverablePlant[] + >({ + queryKey: ["deliverable-plants"], + queryFn: getDeliverablePlants, + select: data => data.result, + }); + +export const useCreateSeedDelivery = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: CreateSeedDeliveryRequest) => postSeedDelivery(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["home-summary"] }); + }, + }); +}; + +export const useUnlockGarden = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => postUnlockGarden(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["home-summary"] }); + }, + }); +}; diff --git a/src/hooks/feed/useFeedLikeToggle.ts b/src/hooks/feed/useFeedLikeToggle.ts new file mode 100644 index 0000000..ac6d26c --- /dev/null +++ b/src/hooks/feed/useFeedLikeToggle.ts @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; + +import { + likeFeedTarget, + unlikeFeedTarget, + type FeedLikeTargetType, +} from "@/apis/feed/likeApi"; + +type Params = { + targetId: number; + targetType: FeedLikeTargetType; + initialLiked: boolean; + initialLikeCount: number; + onSuccessRefetch?: () => void | Promise; +}; + +export default function useFeedLikeToggle({ + targetId, + targetType, + initialLiked, + initialLikeCount, + onSuccessRefetch, +}: Params) { + const [liked, setLiked] = useState(initialLiked); + const [likeCount, setLikeCount] = useState(initialLikeCount); + + useEffect(() => { + setLiked(initialLiked); + setLikeCount(initialLikeCount); + }, [initialLiked, initialLikeCount]); + + const mutation = useMutation({ + mutationFn: async (nextLiked: boolean) => { + if (!nextLiked) { + await unlikeFeedTarget(targetId, targetType); + return false; + } + + await likeFeedTarget(targetId, targetType); + return true; + }, + onMutate: nextLiked => { + const nextLikeCount = Math.max(0, likeCount + (nextLiked ? 1 : -1)); + + setLiked(nextLiked); + setLikeCount(nextLikeCount); + + return { previousLiked: liked, previousLikeCount: likeCount }; + }, + onError: (_error, _variables, context) => { + if (!context) { + return; + } + + setLiked(context.previousLiked); + setLikeCount(context.previousLikeCount); + }, + onSuccess: () => { + void onSuccessRefetch?.(); + }, + }); + + return { + liked, + likeCount, + toggleLike: () => { + if (mutation.isPending) { + return; + } + + const nextLiked = !liked; + mutation.mutate(nextLiked); + }, + isLikePending: mutation.isPending, + }; +} diff --git a/src/hooks/feed/useRandomFeedSession.ts b/src/hooks/feed/useRandomFeedSession.ts new file mode 100644 index 0000000..d50a86a --- /dev/null +++ b/src/hooks/feed/useRandomFeedSession.ts @@ -0,0 +1,54 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { + getRandomFeedSessionNext, + startRandomFeedSession, +} from "@/apis/feed/randomFeedApi"; +import type { + RandomFeedSessionNextRequest, + RandomFeedSessionPayload, +} from "@/types/feed/randomFeedApi.type"; + +type SessionPageParam = string | null; + +export const useRandomFeedSession = ({ + enabled, + sessionKey, + size = 6, +}: { + enabled: boolean; + sessionKey: string; + size?: number; +}) => + useInfiniteQuery< + { result: RandomFeedSessionPayload }, + unknown, + RandomFeedSessionPayload, + [string, string, number], + SessionPageParam + >({ + queryKey: ["random-feed-session", sessionKey, size], + enabled, + initialPageParam: null, + queryFn: ({ pageParam }) => { + if (!pageParam) { + return startRandomFeedSession({ size }); + } + + const payload: RandomFeedSessionNextRequest = { + sessionToken: pageParam, + size, + }; + return getRandomFeedSessionNext(payload); + }, + select: data => ({ + sessionToken: + data.pages[data.pages.length - 1]?.result.sessionToken ?? "", + items: data.pages.flatMap(page => page.result.items), + hasMore: data.pages[data.pages.length - 1]?.result.hasMore ?? false, + remaining: data.pages[data.pages.length - 1]?.result.remaining ?? 0, + }), + getNextPageParam: lastPage => + lastPage.result.hasMore ? lastPage.result.sessionToken : undefined, + }); + +export default useRandomFeedSession; diff --git a/src/hooks/follow/useFollowApi.ts b/src/hooks/follow/useFollowApi.ts new file mode 100644 index 0000000..d07aff5 --- /dev/null +++ b/src/hooks/follow/useFollowApi.ts @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + deleteFollowUser, + getFollowers, + getFollowing, + postFollowUser, +} from "@/apis/follow/followApi"; +import type { FollowResponse } from "@/types/follow"; +import { FollowStatus, type GetUserProfileResponse } from "@/types/profile/profileApi.type"; + +type ProfileCache = { result: GetUserProfileResponse }; +type FollowMutateContext = { previous: ProfileCache | undefined; queryKey: string[] }; + +export const useFollowing = (userId: string | undefined) => + useQuery< + { result: FollowResponse["result"] }, + unknown, + FollowResponse["result"] + >({ + queryKey: ["following", userId], + queryFn: () => getFollowing(userId ?? ""), + select: data => data.result, + enabled: !!userId, + refetchOnMount: "always", + }); + +export const useFollowers = (userId: string | undefined) => + useQuery< + { result: FollowResponse["result"] }, + unknown, + FollowResponse["result"] + >({ + queryKey: ["followers", userId], + queryFn: () => getFollowers(userId ?? ""), + select: data => data.result, + enabled: !!userId, + refetchOnMount: "always", + }); + +export const useFollowUser = (userId: string | undefined) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (targetUserId: string | number) => postFollowUser(targetUserId), + onMutate: async (targetUserId: string | number) => { + const queryKey = ["profile", String(targetUserId)]; + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, old => + old ? { ...old, result: { ...old.result, followStatus: FollowStatus.FOLLOWING } } : old + ); + return { previous, queryKey }; + }, + onError: (_err, _targetUserId, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(context.queryKey, context.previous); + } + }, + onSuccess: async (_, targetUserId) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["profile", String(targetUserId)] }), + queryClient.invalidateQueries({ queryKey: ["following", userId] }), + queryClient.invalidateQueries({ queryKey: ["followers", userId] }), + ]); + }, + }); +}; + +export const useUnfollowUser = (userId: string | undefined) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (targetUserId: string | number) => deleteFollowUser(targetUserId), + onMutate: async (targetUserId: string | number) => { + const queryKey = ["profile", String(targetUserId)]; + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, old => + old ? { ...old, result: { ...old.result, followStatus: FollowStatus.NOT_FOLLOWING } } : old + ); + return { previous, queryKey }; + }, + onError: (_err, _targetUserId, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(context.queryKey, context.previous); + } + }, + onSuccess: async (_, targetUserId) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["profile", String(targetUserId)] }), + queryClient.invalidateQueries({ queryKey: ["following", userId] }), + queryClient.invalidateQueries({ queryKey: ["followers", userId] }), + ]); + }, + }); +}; diff --git a/src/hooks/home/useHomeApi.ts b/src/hooks/home/useHomeApi.ts new file mode 100644 index 0000000..4d90d2f --- /dev/null +++ b/src/hooks/home/useHomeApi.ts @@ -0,0 +1,210 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + getGuestbookList, + getHomePanel, + getHomeSummary, + getNotifications, + getTrackingPromptStatus, + patchAllNotificationsRead, + postGardenMyWater, + postGardenSunlight, + postTrackingPromptConfirm, +} from "@/apis/home/homeApi"; +import type { GlobalResponse } from "@/types/common/apiResponse.type"; +import type { GuestbookEntry, NotificationItem } from "@/types/home/alerts"; +import type { HomeSummaryPayload } from "@/types/home/garden"; +import type { HomePanelPayload } from "@/types/home/panel"; +import type { + TrackingPromptConfirmRequest, + TrackingPromptStatusPayload, +} from "@/types/home/tracking"; +import { createTimingLogger, debugLog } from "@/utils/debug"; + +export const useHomeApi = () => + useQuery< + GlobalResponse, + AxiosError, + HomeSummaryPayload + >({ + queryKey: ["home-summary"], + queryFn: getHomeSummary, + select: data => data.result, + refetchOnMount: "always", + }); + +export const useHomePanelApi = () => + useQuery, AxiosError, HomePanelPayload>({ + queryKey: ["home-panel"], + queryFn: getHomePanel, + select: data => data.result, + refetchOnMount: "always", + // 한글 주석: + // 미션 진행률은 1분 이내 재진입 시 캐시를 그대로 사용. + // 가든 씬(home-summary)보다 덜 긴급해서 background fetch 우선순위를 낮춤. + staleTime: 60_000, + }); + +export const useTrackingPromptStatus = () => + useQuery< + GlobalResponse, + AxiosError, + TrackingPromptStatusPayload + >({ + // 한글 주석: + // 홈 진입, 홈 복귀, 액션 성공 뒤 모두 같은 키를 invalidate/refetch 해서 + // tracking 리포트 노출 여부를 한 군데 기준으로 맞춘다. + queryKey: ["tracking-report-status"], + queryFn: getTrackingPromptStatus, + select: data => data.result, + refetchOnMount: "always", + }); + +export const useTrackingPromptConfirm = () => + useMutation< + GlobalResponse>, + AxiosError, + TrackingPromptConfirmRequest + >({ + mutationFn: postTrackingPromptConfirm, + }); + +export const useNotifications = (enabled: boolean) => + useQuery, AxiosError, NotificationItem[]>({ + queryKey: ["notifications"], + queryFn: getNotifications, + select: data => data.result, + enabled, + }); + +export const useReadNotifications = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: patchAllNotificationsRead, + onMutate: async () => { + await Promise.all([ + queryClient.cancelQueries({ queryKey: ["notifications"] }), + queryClient.cancelQueries({ queryKey: ["home-summary"] }), + ]); + + const previousNotifications = + queryClient.getQueryData>(["notifications"]); + const previousHomeSummary = + queryClient.getQueryData>(["home-summary"]); + + queryClient.setQueryData>( + ["notifications"], + previous => + previous + ? { + ...previous, + result: previous.result.map(item => ({ + ...item, + isRead: true, + read: true, + })), + } + : previous + ); + + queryClient.setQueryData>( + ["home-summary"], + previous => + previous + ? { + ...previous, + result: { + ...previous.result, + userInfo: { + ...previous.result.userInfo, + unreadNotificationCount: 0, + }, + }, + } + : previous + ); + + return { previousNotifications, previousHomeSummary }; + }, + onError: (_error, _variables, context) => { + if (context?.previousNotifications) { + queryClient.setQueryData(["notifications"], context.previousNotifications); + } + + if (context?.previousHomeSummary) { + queryClient.setQueryData(["home-summary"], context.previousHomeSummary); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["notifications"] }); + void queryClient.invalidateQueries({ queryKey: ["home-summary"] }); + }, + }); +}; + +export const useGuestbookList = (userId: number | null, enabled: boolean) => + useQuery, AxiosError, GuestbookEntry[]>({ + queryKey: ["guestbook-list", userId], + queryFn: () => getGuestbookList(userId as number), + select: data => data.result, + enabled: enabled && userId !== null, + }); + +export const useGardenSunlightAction = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (gardenId: number) => postGardenSunlight(gardenId), + onSuccess: () => { + const finishRefreshTiming = createTimingLogger( + "useGardenSunlightAction", + "post-action background refresh" + ); + + void Promise.allSettled([ + queryClient.invalidateQueries({ queryKey: ["home-summary"] }), + queryClient.invalidateQueries({ queryKey: ["home-panel"] }), + queryClient.invalidateQueries({ queryKey: ["tracking-report-status"] }), + ]).then(results => { + const rejectedCount = results.filter(result => result.status === "rejected").length; + + finishRefreshTiming({ rejectedCount }); + + if (rejectedCount > 0) { + debugLog("useGardenSunlightAction", "background refresh had failures", { results }); + } + }); + }, + }); +}; + +export const useGardenWaterAction = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (gardenId: number) => postGardenMyWater(gardenId), + onSuccess: () => { + const finishRefreshTiming = createTimingLogger( + "useGardenWaterAction", + "post-action background refresh" + ); + + void Promise.allSettled([ + queryClient.invalidateQueries({ queryKey: ["home-summary"] }), + queryClient.invalidateQueries({ queryKey: ["home-panel"] }), + queryClient.invalidateQueries({ queryKey: ["tracking-report-status"] }), + ]).then(results => { + const rejectedCount = results.filter(result => result.status === "rejected").length; + + finishRefreshTiming({ rejectedCount }); + + if (rejectedCount > 0) { + debugLog("useGardenWaterAction", "background refresh had failures", { results }); + } + }); + }, + }); +}; + +export default useHomeApi; diff --git a/src/hooks/mission/useMissionApi.ts b/src/hooks/mission/useMissionApi.ts new file mode 100644 index 0000000..2b71567 --- /dev/null +++ b/src/hooks/mission/useMissionApi.ts @@ -0,0 +1,143 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + answerDailySurveyApi, + answerQuizApi, + getDailySurveyApi, + getQuizApi, + getTodayKeywordApi, + uploadDiaryImageApi, + writeDiaryApi, +} from "@/apis/missions/missionApi"; +import type { + AnswerDailySurveyRequest, + AnswerDailySurveyResponse, + AnswerQuizRequest, + AnswerQuizResponse, + DailySurvey, + DiaryImageUploadResponse, + GetDailySurveyResponse, + GetQuizRequest, + GetQuizResponse, + GetTodayKeywordResponse, + MissionQuiz, + TodayKeyword, + WriteDiaryRequest, + WriteDiaryResponse, +} from "@/types/missions"; +import type { GlobalResponse } from "@/types/common/apiResponse.type"; +import type { HomePanelPayload } from "@/types/home/panel"; +import { createTimingLogger, debugLog } from "@/utils/debug"; + +export const useWriteDiaryImageUpload = () => + useMutation({ + mutationFn: formData => uploadDiaryImageApi(formData), + }); + +export const useWriteDiarySubmit = () => + useMutation({ + mutationFn: payload => writeDiaryApi(payload), + }); + +export const useTodayKeyword = () => + useQuery({ + queryKey: ["today-keyword"], + queryFn: getTodayKeywordApi, + select: data => data.result, + staleTime: 5 * 60_000, + }); + +export const useMissionQuiz = (params: GetQuizRequest) => + useQuery({ + queryKey: ["mission-quiz", params.quizType], + queryFn: () => getQuizApi(params), + select: data => data.result, + }); + +export const useAnswerQuiz = () => { + const queryClient = useQueryClient(); + + return useMutation< + AnswerQuizResponse, + Error, + AnswerQuizRequest, + { previous: GlobalResponse | undefined } + >({ + mutationFn: payload => answerQuizApi(payload), + onMutate: async () => { + /* + * 한글 주석: + * 퀴즈 정답 제출 즉시 홈 패널 캐시의 isQuizCompleted를 true로 설정해 + * 홈으로 돌아왔을 때 완료 애니메이션이 지연 없이 표시된다. + */ + await queryClient.cancelQueries({ queryKey: ["home-panel"] }); + const previous = queryClient.getQueryData>(["home-panel"]); + queryClient.setQueryData>(["home-panel"], old => + old ? { ...old, result: { ...old.result, isQuizCompleted: true } } : old + ); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(["home-panel"], context.previous); + } + }, + onSuccess: async () => { + /* + * 한글 주석: + * 퀴즈 완료 직후 홈 미션 패널과 홈 요약을 함께 갱신해야 + * 사용자가 홈으로 돌아왔을 때 완료 상태가 즉시 반영된다. + */ + await queryClient.invalidateQueries({ queryKey: ["home-summary"] }); + await queryClient.invalidateQueries({ queryKey: ["home-panel"] }); + await queryClient.refetchQueries({ queryKey: ["home-summary"], type: "all" }); + await queryClient.refetchQueries({ queryKey: ["home-panel"], type: "all" }); + }, + }); +}; + +export const useDailySurvey = () => + useQuery({ + queryKey: ["daily-survey"], + queryFn: getDailySurveyApi, + select: data => data.result, + // 한글 주석: + // 감정 설문은 하루 1회만 바뀌므로 5분간 캐시 유지. + // 홈 재진입 시 불필요한 재요청 없이 즉시 감정 버튼 상태 표시. + staleTime: 5 * 60_000, + }); + +export const useAnswerDailySurvey = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: payload => answerDailySurveyApi(payload), + onSuccess: () => { + /* + * Keep the survey modal responsive. + * Refresh related queries in the background after submit succeeds. + */ + const finishRefreshTiming = createTimingLogger( + "useAnswerDailySurvey", + "post-submit background refresh" + ); + + void Promise.allSettled([ + queryClient.invalidateQueries({ queryKey: ["daily-survey"] }), + queryClient.invalidateQueries({ queryKey: ["home-summary"] }), + queryClient.invalidateQueries({ queryKey: ["home-panel"] }), + queryClient.refetchQueries({ queryKey: ["daily-survey"], type: "all" }), + queryClient.refetchQueries({ queryKey: ["home-summary"], type: "all" }), + queryClient.refetchQueries({ queryKey: ["home-panel"], type: "all" }), + ]).then(results => { + const rejectedCount = results.filter(result => result.status === "rejected").length; + + finishRefreshTiming({ rejectedCount }); + + if (rejectedCount > 0) { + debugLog("useAnswerDailySurvey", "background refresh had failures", { results }); + } + }); + }, + }); +}; + diff --git a/src/hooks/option/useAvatarNicknameApi.ts b/src/hooks/option/useAvatarNicknameApi.ts new file mode 100644 index 0000000..a8be8e2 --- /dev/null +++ b/src/hooks/option/useAvatarNicknameApi.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateAvatarNickname, type UpdateAvatarPayload } from "@/apis/option/avatarApi"; + +export const useUpdateAvatarNickname = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: UpdateAvatarPayload) => updateAvatarNickname(payload), + onSuccess: async () => { + // 한글 주석: + // 아바타 닉네임은 홈, 프로필, 피드에 동시에 노출되므로 + // 저장 직후 관련 캐시를 함께 갱신해 같은 식물이 다른 이름으로 남지 않게 맞춘다. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["home-summary"] }), + queryClient.invalidateQueries({ queryKey: ["profile"] }), + queryClient.invalidateQueries({ queryKey: ["feed-detail"] }), + ]); + }, + }); +}; diff --git a/src/hooks/option/useDeleteAccount.ts b/src/hooks/option/useDeleteAccount.ts new file mode 100644 index 0000000..efdb1d0 --- /dev/null +++ b/src/hooks/option/useDeleteAccount.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteMeApi } from "@/apis/option/userApi"; +import { clearLocalSession } from "@/utils/auth"; + +export const useDeleteAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteMeApi, + onSuccess: async () => { + await clearLocalSession(); + queryClient.clear(); + }, + }); +}; diff --git a/src/hooks/option/useNotificationApi.ts b/src/hooks/option/useNotificationApi.ts new file mode 100644 index 0000000..c27e01d --- /dev/null +++ b/src/hooks/option/useNotificationApi.ts @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + deleteFcmToken, + getNotificationSettings, + patchNotificationSettings, + postFcmToken, + type NotificationSettings, +} from "@/apis/option/notificationApi"; +import type { GlobalResponse } from "@/types/common/apiResponse.type"; + +const SETTINGS_QUERY_KEY = ["notificationSettings"]; + +export const useNotificationSettings = () => + useQuery({ + queryKey: SETTINGS_QUERY_KEY, + queryFn: getNotificationSettings, + select: data => data.result, + }); + +export const useUpdateNotificationSettings = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: patchNotificationSettings, + onMutate: async newSettings => { + await queryClient.cancelQueries({ queryKey: SETTINGS_QUERY_KEY }); + const previous = queryClient.getQueryData>(SETTINGS_QUERY_KEY); + queryClient.setQueryData>(SETTINGS_QUERY_KEY, old => + old ? { ...old, result: { ...old.result, ...newSettings } } : old + ); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(SETTINGS_QUERY_KEY, context.previous); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY }); + }, + }); +}; + +export const useRegisterNotificationToken = () => + useMutation({ + mutationFn: postFcmToken, + }); + +export const useDeleteNotificationToken = () => + useMutation({ + mutationFn: deleteFcmToken, + }); diff --git a/src/hooks/option/usePolicyApi.ts b/src/hooks/option/usePolicyApi.ts new file mode 100644 index 0000000..273ff2b --- /dev/null +++ b/src/hooks/option/usePolicyApi.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPolicy } from "@/apis/option/policyApi"; + +export const usePolicy = () => + useQuery<{ result: string }, unknown, string>({ + queryKey: ["policy"], + queryFn: () => getPolicy(), + select: data => data.result, + }); diff --git a/src/hooks/profile/useGuestbookApi.ts b/src/hooks/profile/useGuestbookApi.ts new file mode 100644 index 0000000..5b9da3e --- /dev/null +++ b/src/hooks/profile/useGuestbookApi.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { GlobalResponse } from "@/types/common/apiResponse.type"; +import type { + CreateGuestbookRequest, + GuestbookEntry, +} from "@/types/profile/guestbookApi.type"; +import { getGuestbookList, postGuestbook } from "@/apis/profile/guestbookApi"; +import { useHomeSummaryStore } from "@/stores/useHomeSummaryStore"; + +export const useGuestbookList = (userId: string | number | undefined) => + useQuery, AxiosError, GuestbookEntry[]>({ + queryKey: ["guestbook-list", userId], + queryFn: () => getGuestbookList(String(userId)), + select: data => data.result, + enabled: !!userId, + refetchOnMount: "always", + }); + +export const useCreateGuestbook = (userId: string | number | undefined) => { + const queryClient = useQueryClient(); + const currentUsername = useHomeSummaryStore(state => state.user?.username ?? ""); + + return useMutation< + unknown, + AxiosError, + CreateGuestbookRequest, + { previous: GlobalResponse | undefined; queryKey: (string | number | undefined)[] } + >({ + mutationFn: (body: CreateGuestbookRequest) => postGuestbook(String(userId), body), + onMutate: async (body: CreateGuestbookRequest) => { + const queryKey = ["guestbook-list", userId]; + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData>(queryKey); + const optimisticEntry: GuestbookEntry = { + author: currentUsername, + content: body.content, + createdAt: new Date().toISOString(), + }; + queryClient.setQueryData>(queryKey, old => + old ? { ...old, result: [optimisticEntry, ...old.result] } : old + ); + return { previous, queryKey }; + }, + onError: (_err, _body, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(context.queryKey, context.previous); + } + }, + onSuccess: async () => { + /* + * 한글 주석: + * 방명록 작성 후에는 현재 프로필 화면의 목록뿐 아니라 + * 홈 비둘기 모달이 바라보는 방명록/알림 조회도 함께 새로 읽게 맞춘다. + */ + await queryClient.invalidateQueries({ queryKey: ["guestbook-list"] }); + await queryClient.invalidateQueries({ queryKey: ["notifications"] }); + }, + }); +}; diff --git a/src/hooks/profile/useProfileApi.ts b/src/hooks/profile/useProfileApi.ts new file mode 100644 index 0000000..5ccd0cf --- /dev/null +++ b/src/hooks/profile/useProfileApi.ts @@ -0,0 +1,61 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getUserProfile, + patchMyNickname, + postFriendWater, +} from "@/apis/profile/profileApi"; +import type { GetUserProfileResponse } from "@/types/profile/profileApi.type"; +import { createTimingLogger, debugLog } from "@/utils/debug"; + +export const useUserProfile = (userId: string | number | undefined) => + useQuery<{ result: GetUserProfileResponse }, unknown, GetUserProfileResponse>({ + queryKey: ["profile", userId], + queryFn: () => getUserProfile(String(userId)), + select: data => data.result, + enabled: !!userId, + refetchOnMount: "always", + }); + +export const useFriendWater = (userId: string | number | undefined) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (gardenId: number) => postFriendWater(gardenId), + onSuccess: () => { + const finishRefreshTiming = createTimingLogger( + "useFriendWater", + "post-action background refresh" + ); + + void Promise.allSettled([ + queryClient.invalidateQueries({ queryKey: ["profile", userId] }), + ]).then(results => { + const rejectedCount = results.filter(result => result.status === "rejected").length; + + finishRefreshTiming({ rejectedCount }); + + if (rejectedCount > 0) { + debugLog("useFriendWater", "background refresh had failures", { results, userId }); + } + }); + }, + }); +}; + +export const useUpdateMyNickname = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (newNickname: string) => patchMyNickname(newNickname), + onSuccess: async () => { + // 한글 주석: + // 유저 닉네임은 프로필, 방명록, 알림 문구에 함께 노출되므로 + // 저장 직후 관련 캐시를 함께 갱신해서 임시 닉네임이 남지 않게 맞춘다. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["profile"] }), + queryClient.invalidateQueries({ queryKey: ["guestbook-list"] }), + queryClient.invalidateQueries({ queryKey: ["notifications"] }), + ]); + }, + }); +}; diff --git a/src/hooks/register/useRegisterApi.ts b/src/hooks/register/useRegisterApi.ts index b64a060..18e7e1b 100644 --- a/src/hooks/register/useRegisterApi.ts +++ b/src/hooks/register/useRegisterApi.ts @@ -7,7 +7,7 @@ import { registerApi } from "@/apis/register/registerApi"; // useRegisterApi는 TanStack Query의 useMutation을 정의해서 반환 export const useRegisterApi = () => { - const { setAccessToken, setRefreshToken, setUserId } = useTokenStore(); + const { setAuth } = useTokenStore(); const postRegisterMutation = useMutation< GlobalResponse, @@ -16,9 +16,11 @@ export const useRegisterApi = () => { >({ mutationFn: (nickname: string) => registerApi(nickname), onSuccess: res => { - setAccessToken(res.result.accessToken); - setRefreshToken(res.result.refreshToken); - setUserId(String(res.result.userId)); + setAuth({ + accessToken: res.result.accessToken, + refreshToken: res.result.refreshToken, + userId: String(res.result.userId), + }); }, }); diff --git a/src/hooks/report/useReportApi.ts b/src/hooks/report/useReportApi.ts new file mode 100644 index 0000000..203668b --- /dev/null +++ b/src/hooks/report/useReportApi.ts @@ -0,0 +1,10 @@ +import { useMutation } from "@tanstack/react-query"; +import { postReport } from "@/apis/report/reportApi"; +import type { CreateReportPayload } from "@/types/report"; + +export const useCreateReport = () => + useMutation({ + mutationFn: (payload: CreateReportPayload) => postReport(payload), + }); + +export default useCreateReport; diff --git a/src/navigation/MainTabNavigator.tsx b/src/navigation/MainTabNavigator.tsx index f05c17e..09bed9d 100644 --- a/src/navigation/MainTabNavigator.tsx +++ b/src/navigation/MainTabNavigator.tsx @@ -1,13 +1,11 @@ +import { useEffect } from "react"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import type { MainTabParamList } from "./types"; -// 스크린 임포트 import HomeScreen from "@/pages/home/HomeScreen"; import FeedScreen from "@/pages/feed/FeedScreen"; import LogScreen from "@/pages/log/LogScreen"; import OptionScreen from "@/pages/option/OptionScreen"; - -// 아이콘 임포트 import { HomeIcon, CalendarIcon, @@ -16,10 +14,15 @@ import { ACTIVE_COLOR, INACTIVE_COLOR, } from "@/assets/icons/TabIcons"; +import { debugLog } from "@/utils/debug"; const Tab = createBottomTabNavigator(); export default function MainTabNavigator() { + useEffect(() => { + debugLog("MainTabNavigator", "mounted"); + }, []); + return ( (); -function ProfileScreen() { - return ; -} - -function FollowScreen() { - return ; -} - -function LogDetailScreen() { - return ; -} - -function DeliveryScreen() { - return ; -} - -function DeliveryCompleteScreen() { - return ; -} - -function UnlockGardenScreen() { - return ( - - ); -} - -// 등록 플로우 스크린들 -function RegistrationAvatarScreen() { - return ( - - ); -} - -function RegistrationCreationDetailScreen() { - return ( - - ); -} - -function RegistrationSelectionDetailScreen() { - return ( - - ); -} - -function RegistrationPlantNicknameScreen() { - return ( - - ); -} - -// 데일리 미션 스크린들 -function DailyMissionWriteDiaryScreen() { - return ; -} +export default function RootNavigator() { + const { accessToken, hasHydrated } = useTokenStore(); + const isAuthenticated = Boolean(accessToken); -function DailyMissionQuizMultipleChoiceScreen() { - return ; -} + debugLog("RootNavigator", "render", { + hasHydrated, + isAuthenticated, + hasAccessToken: Boolean(accessToken), + }); -function DailyMissionQuizOxScreen() { - return ; -} + if (!hasHydrated) { + debugLog("RootNavigator", "waiting for token hydration"); + return ; + } -export default function RootNavigator() { return ( - {/* 메인 탭 네비게이터 */} - - - {/* 인증/온보딩 */} + {isAuthenticated ? ( + <> + + + ) : ( + <> + + + + )} + + + + + + + + + + + + + + + + + - - - {/* 프로필 */} - - - {/* 팔로우 */} - - {/* 피드 상세 */} - - - {/* 로그 상세 */} - - - {/* 배송 */} - - - - - {/* 식물 등록 플로우 */} - - - - {/* 데일리 미션 */} + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 3f2cb34..811132f 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,4 +1,4 @@ -import type { BottomTabScreenProps } from "@react-navigation/bottom-tabs"; +import type { BottomTabScreenProps } from "@react-navigation/bottom-tabs"; import type { CompositeScreenProps, NavigatorScreenParams, @@ -15,9 +15,22 @@ export type RootStackParamList = { // 인증/온보딩 Onboarding: undefined; Register: undefined; + SocialNickname: { initialNickname?: string } | undefined; // 프로필 Profile: { userId: number }; + Guestbook: { userId: number; userNickname?: string }; + + // 설정 + UserNicknameEdit: undefined; + AvatarNicknameEdit: undefined; + AvatarNicknameEditStep2: { + avatarId: number; + avatarName: string; + avatarImageUrl: string; + }; + Policy: undefined; + ServiceGuide: undefined; // 팔로우 Follow: undefined; @@ -30,20 +43,61 @@ export type RootStackParamList = { LogDetail: { id: number }; // 배송 - Delivery: undefined; - DeliveryComplete: undefined; - UnlockGarden: undefined; + Delivery: + | { + seedType?: number; + seedName?: string; + gardenId?: number; + gardenSlotNumber?: number; + } + | undefined; + DeliveryComplete: + | { + seedName?: string; + gardenId?: number; + gardenSlotNumber?: number; + } + | undefined; + UnlockGarden: + | { + gardenId?: number; + gardenSlotNumber?: number; + } + | undefined; // 식물 등록 플로우 - RegistrationAvatar: undefined; - RegistrationCreationDetail: undefined; - RegistrationSelectionDetail: undefined; + RegistrationAvatar: + | { + entry?: "initial" | "garden"; + } + | undefined; + RegistrationCreationDetail: + | { + entry?: "initial" | "garden"; + } + | undefined; + RegistrationCreationPending: { + entry?: "initial" | "garden"; + imageUri: string; + fileName: string; + fileType: string; + }; + RegistrationCreationComplete: { + entry?: "initial" | "garden"; + imageUrl: string; + }; + RegistrationSelectionDetail: + | { + entry?: "initial" | "garden"; + } + | undefined; RegistrationPlantNickname: undefined; // 데일리 미션 DailyMissionWriteDiary: undefined; DailyMissionQuizMultipleChoice: undefined; DailyMissionQuizOx: undefined; + DailyMissionChecking: undefined; }; /** diff --git a/src/pages/dailyMission/DailyMissionCheckingScreen.tsx b/src/pages/dailyMission/DailyMissionCheckingScreen.tsx new file mode 100644 index 0000000..a7613ba --- /dev/null +++ b/src/pages/dailyMission/DailyMissionCheckingScreen.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import QuizOptionCard from "@/components/dailyMission/QuizOptionCard"; +import RegistrationFooter from "@/components/registration/RegistrationFooter"; +import { + useAnswerDailySurvey, + useDailySurvey, +} from "@/hooks/mission/useMissionApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import type { ErrorResponse } from "@/types/common/apiResponse.type"; +import { + SURVEY_ANSWER_VALUE_MAP, + type SurveyAnswerKind, +} from "@/types/missions"; + +type Props = RootStackScreenProps<"DailyMissionChecking">; + +type SurveyOption = { + kind: SurveyAnswerKind; + label: string; + description: string; +}; + +const SURVEY_OPTIONS: SurveyOption[] = [ + { kind: "YES", label: "네", description: "YES" }, + { kind: "NEUTRAL", label: "그저 그래요", description: "NEUTRAL" }, + { kind: "NO", label: "아니요", description: "NO" }, +]; + +export default function DailyMissionCheckingScreen({ navigation }: Props) { + const queryClient = useQueryClient(); + const [selected, setSelected] = useState(null); + const [answeredLocally, setAnsweredLocally] = useState(false); + const [submitErrorMessage, setSubmitErrorMessage] = useState(null); + const { data, isLoading, isError, refetch } = useDailySurvey(); + const answerSurvey = useAnswerDailySurvey(); + + const goHome = () => + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }); + + const handleSubmit = async () => { + if (!data?.id || !selected || answerSurvey.isPending || data.isAnswered || answeredLocally) { + return; + } + + setSubmitErrorMessage(null); + + try { + await answerSurvey.mutateAsync({ + questionId: data.id, + answer: SURVEY_ANSWER_VALUE_MAP[selected], + }); + + await queryClient.refetchQueries({ queryKey: ["daily-survey"], type: "all" }); + await queryClient.refetchQueries({ queryKey: ["home-summary"], type: "all" }); + await queryClient.refetchQueries({ queryKey: ["home-panel"], type: "all" }); + setAnsweredLocally(true); + } catch (error) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + const serverMessage = axiosError.response?.data?.message; + + if (status === 409) { + setAnsweredLocally(true); + setSubmitErrorMessage("이미 오늘의 질문에 답변했습니다. 홈으로 돌아가 완료 상태를 확인해주세요."); + await queryClient.refetchQueries({ queryKey: ["daily-survey"], type: "all" }); + await queryClient.refetchQueries({ queryKey: ["home-summary"], type: "all" }); + await queryClient.refetchQueries({ queryKey: ["home-panel"], type: "all" }); + return; + } + + setSubmitErrorMessage(serverMessage ?? "답변 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + }; + + if (isLoading) { + return ( + + navigation.goBack()} /> + + + ); + } + + if (isError) { + return ( + + navigation.goBack()} /> + void refetch()} + /> + + ); + } + + if (!data) { + return ( + + navigation.goBack()} /> + + + ); + } + + const isAnswered = data.isAnswered || answeredLocally || answerSurvey.isSuccess; + + return ( + + navigation.goBack()} /> + + + 오늘의 체크인 + {data.question} + + 하루에 한 번만 답할 수 있고, 답변 완료 시 홈 미션 상태가 갱신됩니다. + + + + + {SURVEY_OPTIONS.map(option => ( + + setSelected(option.kind)} + /> + {option.description} + + ))} + + + {isAnswered ? ( + + 오늘의 질문에 이미 답변했습니다. + + 중복 저장은 막혀 있으며, 홈으로 돌아가면 완료 상태를 볼 수 있습니다. + + + ) : null} + + {submitErrorMessage ? ( + + 답변 저장 안내 + {submitErrorMessage} + + ) : null} + + + void handleSubmit()} + primaryDisabled={(!selected && !isAnswered) || answerSurvey.isPending} + primaryLoading={answerSurvey.isPending} + /> + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#F7F8F4", + }, + content: { + padding: 20, + gap: 18, + }, + headerBlock: { + gap: 8, + }, + eyebrow: { + fontSize: 12, + color: "#2F7D32", + fontWeight: "700", + }, + question: { + fontSize: 24, + lineHeight: 32, + fontWeight: "700", + color: "#171717", + }, + subtitle: { + fontSize: 14, + lineHeight: 20, + color: "#6B7280", + }, + options: { + gap: 12, + }, + optionBlock: { + gap: 6, + }, + optionDescription: { + fontSize: 12, + color: "#6B7280", + paddingHorizontal: 4, + }, + successCard: { + borderRadius: 16, + padding: 16, + backgroundColor: "#EDF7ED", + gap: 6, + }, + successTitle: { + fontSize: 16, + fontWeight: "700", + color: "#1F5C27", + }, + successDescription: { + fontSize: 14, + lineHeight: 20, + color: "#2F5D3B", + }, + errorCard: { + borderRadius: 16, + padding: 16, + backgroundColor: "#FEF2F2", + gap: 6, + }, + errorTitle: { + fontSize: 16, + fontWeight: "700", + color: "#B91C1C", + }, + errorDescription: { + fontSize: 14, + lineHeight: 20, + color: "#7F1D1D", + }, +}); diff --git a/src/pages/dailyMission/DailyMissionQuizMultipleChoiceScreen.tsx b/src/pages/dailyMission/DailyMissionQuizMultipleChoiceScreen.tsx new file mode 100644 index 0000000..f0e95d9 --- /dev/null +++ b/src/pages/dailyMission/DailyMissionQuizMultipleChoiceScreen.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useState } from "react"; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import QuizOptionCard from "@/components/dailyMission/QuizOptionCard"; +import { useAnswerQuiz, useMissionQuiz } from "@/hooks/mission/useMissionApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import type { AnswerQuizResult } from "@/types/missions"; + +type Props = RootStackScreenProps<"DailyMissionQuizMultipleChoice">; + +export default function DailyMissionQuizMultipleChoiceScreen({ navigation }: Props) { + const [selected, setSelected] = useState(null); + const { data, isLoading, isError, refetch } = useMissionQuiz({ + quizType: "MULTI_CHOICE", + }); + const submitAnswer = useAnswerQuiz(); + + useEffect(() => { + if (data?.selectedOptionNumber != null) { + setSelected(data.selectedOptionNumber); + } + }, [data?.selectedOptionNumber]); + + const persistedAnswerResult = useMemo(() => { + if ( + !data?.isCompleted || + data.selectedOptionNumber == null || + data.answerNumber == null || + data.isCorrect == null || + data.answerDescription == null + ) { + return null; + } + + return { + isCorrect: data.isCorrect, + answerDescription: data.answerDescription, + answerNumber: data.answerNumber, + isCompleted: true, + selectedOptionNumber: data.selectedOptionNumber, + quizQuestion: data.quizQuestion, + quizType: data.quizType, + }; + }, [data]); + + const answerResult = submitAnswer.data?.result ?? persistedAnswerResult; + + const handleSubmit = async () => { + if (!data?.quizId || selected === null || submitAnswer.isPending || answerResult) { + return; + } + + await submitAnswer.mutateAsync({ + quizId: data.quizId, + selectedOptionOrder: selected, + }); + }; + + const goHome = () => + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }); + + if (isLoading) { + return ( + + navigation.goBack()} /> + + + ); + } + + if (isError) { + return ( + + navigation.goBack()} /> + void refetch()} + /> + + ); + } + + if (!data) { + return ( + + navigation.goBack()} /> + + + ); + } + + return ( + + navigation.goBack()} /> + + + 오늘의 퀴즈! + {data.quizQuestion} + + + + {data.quizOptions?.map(option => { + const isSelected = selected === option.optionOrder; + const isCorrectAnswer = option.optionOrder === answerResult?.answerNumber; + const isWrongSelected = option.optionOrder === answerResult?.selectedOptionNumber && answerResult && !answerResult.isCorrect; + + const state = answerResult + ? isWrongSelected + ? "wrong" + : isCorrectAnswer + ? "correct" + : "idle" + : "idle"; + + const shouldShowExplanation = Boolean(answerResult && isCorrectAnswer); + + return ( + + setSelected(option.optionOrder)} + /> + {shouldShowExplanation ? ( + {answerResult?.answerDescription ?? ""} + ) : null} + + ); + })} + + + {submitAnswer.isError ? ( + + 정답 제출에 실패했습니다. + 잠시 후 다시 시도해주세요. + + ) : null} + + + + void handleSubmit()} + disabled={answerResult ? false : selected === null || submitAnswer.isPending} + style={[ + styles.primaryButton, + !answerResult && (selected === null || submitAnswer.isPending) ? styles.primaryButtonDisabled : null, + answerResult ? styles.primaryButtonResult : null, + ]} + > + + {answerResult ? "다음" : submitAnswer.isPending ? "확인 중..." : "정답 확인하기"} + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + paddingHorizontal: 20, + paddingTop: 34, + paddingBottom: 24, + }, + headerBlock: { + gap: 8, + marginBottom: 32, + }, + title: { + fontSize: 20, + lineHeight: 28, + fontWeight: "600", + color: "#171717", + }, + question: { + fontSize: 16, + lineHeight: 26, + fontWeight: "400", + color: "#171717", + }, + options: { + gap: 8, + }, + optionBlock: { + gap: 8, + }, + explanation: { + fontSize: 14, + lineHeight: 22, + color: "#3AB40B", + }, + errorCard: { + marginTop: 20, + borderRadius: 16, + padding: 16, + backgroundColor: "#FFF4F4", + gap: 6, + }, + errorTitle: { + fontSize: 16, + fontWeight: "700", + color: "#B91C1C", + }, + errorDescription: { + fontSize: 14, + lineHeight: 20, + color: "#7F1D1D", + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 16, + backgroundColor: "#FFFFFF", + }, + primaryButton: { + minHeight: 56, + borderRadius: 8, + backgroundColor: "#72D14E", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: "#72D14E", + }, + primaryButtonDisabled: { + backgroundColor: "#EFEFEF", + borderColor: "#EFEFEF", + }, + primaryButtonResult: { + backgroundColor: "#FFFFFF", + borderColor: "#72D14E", + }, + primaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, + primaryButtonTextDisabled: { + color: "#BFBFBF", + }, + primaryButtonTextResult: { + color: "#3AB40B", + }, +}); + diff --git a/src/pages/dailyMission/DailyMissionQuizOxScreen.tsx b/src/pages/dailyMission/DailyMissionQuizOxScreen.tsx new file mode 100644 index 0000000..ae64508 --- /dev/null +++ b/src/pages/dailyMission/DailyMissionQuizOxScreen.tsx @@ -0,0 +1,310 @@ +import { useEffect, useMemo, useState } from "react"; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import OxQuizOptionCard from "@/components/dailyMission/OxQuizOptionCard"; +import { useAnswerQuiz, useMissionQuiz } from "@/hooks/mission/useMissionApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import type { AnswerQuizResult } from "@/types/missions"; + +type Props = RootStackScreenProps<"DailyMissionQuizOx">; + +export default function DailyMissionQuizOxScreen({ navigation }: Props) { + const [selected, setSelected] = useState(null); + const { data, isLoading, isError, refetch } = useMissionQuiz({ + quizType: "OX", + }); + const submitAnswer = useAnswerQuiz(); + + const options = useMemo( + () => + data?.quizOptions?.length + ? data.quizOptions + : [ + { optionOrder: 0, optionText: "O" }, + { optionOrder: 1, optionText: "X" }, + ], + [data?.quizOptions] + ); + + useEffect(() => { + if (data?.selectedOptionNumber != null) { + setSelected(data.selectedOptionNumber); + } + }, [data?.selectedOptionNumber]); + + const persistedAnswerResult = useMemo(() => { + if ( + !data?.isCompleted || + data.selectedOptionNumber == null || + data.answerNumber == null || + data.isCorrect == null || + data.answerDescription == null + ) { + return null; + } + + return { + isCorrect: data.isCorrect, + answerDescription: data.answerDescription, + answerNumber: data.answerNumber, + isCompleted: true, + selectedOptionNumber: data.selectedOptionNumber, + quizQuestion: data.quizQuestion, + quizType: data.quizType, + }; + }, [data]); + + const answerResult = submitAnswer.data?.result ?? persistedAnswerResult; + + const handleSubmit = async () => { + if (!data?.quizId || selected === null || submitAnswer.isPending || answerResult) { + return; + } + + await submitAnswer.mutateAsync({ + quizId: data.quizId, + selectedOptionOrder: selected, + }); + }; + + const goNext = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }); + }; + + if (isLoading) { + return ( + + navigation.goBack()} /> + + + ); + } + + if (isError) { + return ( + + navigation.goBack()} /> + void refetch()} + /> + + ); + } + + if (!data) { + return ( + + navigation.goBack()} /> + + + ); + } + + return ( + + navigation.goBack()} /> + + + 오늘의 퀴즈! + {data.quizQuestion} + + + + {options.map(option => { + const isCorrectAnswer = option.optionOrder === answerResult?.answerNumber; + const isWrongSelected = + option.optionOrder === answerResult?.selectedOptionNumber && + answerResult != null && + !answerResult.isCorrect; + + const state = answerResult + ? isWrongSelected + ? "wrong" + : isCorrectAnswer + ? "correct" + : "idle" + : selected === option.optionOrder + ? "selected" + : "idle"; + + return ( + setSelected(option.optionOrder)} + /> + ); + })} + + + {answerResult ? ( + + + {answerResult.isCorrect ? "정답!" : "오답!"} + + + {answerResult.answerDescription} + + + ) : null} + + {submitAnswer.isError ? ( + + 정답 제출에 실패했습니다. + 잠시 후 다시 시도해주세요. + + ) : null} + + + + void handleSubmit()} + style={[ + styles.primaryButton, + !answerResult && (selected === null || submitAnswer.isPending) ? styles.primaryButtonDisabled : null, + answerResult ? styles.primaryButtonResult : null, + ]} + > + + {answerResult ? "다음" : submitAnswer.isPending ? "확인 중..." : "정답 확인하기"} + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + paddingHorizontal: 20, + paddingTop: 34, + paddingBottom: 24, + }, + headerBlock: { + gap: 8, + marginBottom: 52, + }, + title: { + fontSize: 20, + lineHeight: 28, + fontWeight: "600", + color: "#171717", + }, + question: { + fontSize: 16, + lineHeight: 26, + fontWeight: "400", + color: "#171717", + }, + optionsRow: { + flexDirection: "row", + gap: 12, + marginBottom: 32, + }, + resultBlock: { + gap: 8, + }, + resultTitle: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + }, + resultTitleCorrect: { + color: "#3AB40B", + }, + resultTitleWrong: { + color: "#F76868", + }, + resultDescription: { + fontSize: 14, + lineHeight: 22, + fontWeight: "400", + }, + resultDescriptionCorrect: { + color: "#3AB40B", + }, + resultDescriptionWrong: { + color: "#F76868", + }, + errorBlock: { + marginTop: 20, + gap: 6, + }, + errorTitle: { + fontSize: 15, + fontWeight: "700", + color: "#B91C1C", + }, + errorDescription: { + fontSize: 13, + lineHeight: 20, + color: "#7F1D1D", + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 16, + backgroundColor: "#FFFFFF", + }, + primaryButton: { + minHeight: 56, + borderRadius: 8, + backgroundColor: "#72D14E", + alignItems: "center", + justifyContent: "center", + }, + primaryButtonDisabled: { + backgroundColor: "#EFEFEF", + }, + primaryButtonResult: { + backgroundColor: "#EEF9EA", + }, + primaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, + primaryButtonTextDisabled: { + color: "#BFBFBF", + }, + primaryButtonTextResult: { + color: "#3AB40B", + }, +}); diff --git a/src/pages/dailyMission/DailyMissionWriteDiaryScreen.tsx b/src/pages/dailyMission/DailyMissionWriteDiaryScreen.tsx new file mode 100644 index 0000000..9a35d38 --- /dev/null +++ b/src/pages/dailyMission/DailyMissionWriteDiaryScreen.tsx @@ -0,0 +1,272 @@ +import * as ImagePicker from "expo-image-picker"; +import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import CheckIcon from "@/assets/icons/Check.svg"; +import Check2Icon from "@/assets/icons/Check2.svg"; +import ImageAttachmentCard from "@/components/dailyMission/ImageAttachmentCard"; +import { + useTodayKeyword, + useWriteDiaryImageUpload, + useWriteDiarySubmit, +} from "@/hooks/mission/useMissionApi"; +import type { RootStackScreenProps } from "@/navigation/types"; + +type Props = RootStackScreenProps<"DailyMissionWriteDiary">; + +export default function DailyMissionWriteDiaryScreen({ navigation }: Props) { + const queryClient = useQueryClient(); + const { data: todayKeyword } = useTodayKeyword(); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [isPublic, setIsPublic] = useState(false); + const [permissionRequested, setPermissionRequested] = useState(false); + const [selectedImageUri, setSelectedImageUri] = useState(null); + const [uploadedImage, setUploadedImage] = useState<{ + imageId: number; + imageUrl: string; + } | null>(null); + const uploadDiaryImage = useWriteDiaryImageUpload(); + const submitDiary = useWriteDiarySubmit(); + + // 홈 화면으로 초기화 이동 + const goHome = () => + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }); + + // 갤러리에서 이미지 선택 후 서버 업로드 + const handlePickImage = async () => { + if (!permissionRequested) { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + setPermissionRequested(true); + + if (!permission.granted) { + Alert.alert("권한 필요", "일기 이미지를 선택하려면 사진 접근 권한이 필요합니다."); + return; + } + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + quality: 0.9, + }); + + if (result.canceled || !result.assets[0]) { + return; + } + + const asset = result.assets[0]; + const fileName = asset.fileName ?? `diary-${Date.now()}.jpg`; + const fileType = asset.mimeType ?? "image/jpeg"; + const formData = new FormData(); + + formData.append( + "file", + { + uri: asset.uri, + name: fileName, + type: fileType, + } as never + ); + + setSelectedImageUri(asset.uri); + setUploadedImage(null); + + try { + const response = await uploadDiaryImage.mutateAsync(formData); + setUploadedImage(response.result); + } catch (error) { + const message = error instanceof Error ? error.message : "이미지 업로드에 실패했습니다."; + Alert.alert("업로드 실패", message); + } + }; + + // 일기 저장 후 관련 쿼리 무효화 및 홈 이동 + const handleSubmit = async () => { + if (!title.trim() || !content.trim()) { + return; + } + + if (!uploadedImage) { + Alert.alert("이미지 필요", "먼저 이미지를 업로드해주세요."); + return; + } + + try { + await submitDiary.mutateAsync({ + title: title.trim(), + content: content.trim(), + isPublic, + imageId: uploadedImage.imageId, + imageUrl: uploadedImage.imageUrl, + }); + + await queryClient.invalidateQueries({ queryKey: ["home-summary"] }); + await queryClient.invalidateQueries({ queryKey: ["calendar"] }); + await queryClient.invalidateQueries({ queryKey: ["diaries"] }); + goHome(); + } catch (error) { + const message = error instanceof Error ? error.message : "일기 저장에 실패했습니다."; + Alert.alert("일기 저장 실패", message); + } + }; + + const isSubmitDisabled = + !title.trim() || + !content.trim() || + !uploadedImage || + uploadDiaryImage.isPending || + submitDiary.isPending; + + const today = new Date().toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); + const keyword = todayKeyword?.keyword.trim(); + const contentPlaceholder = + keyword && !keyword.includes("없습니다") && !keyword.includes("실패") + ? `${keyword}에 대해 이야기 해보는 건 어때요?` + : "오늘 식물에게 있었던 일을 적어주세요"; + + return ( + + {/* 헤더: X 닫기 + 완료 제출 버튼 */} + void handleSubmit()} + rightActionDisabled={isSubmitDisabled} + /> + + + {/* 날짜 + 제목 섹션 (하단 구분선으로 묶음) */} + + {today} + + + + {/* 이미지 첨부 카드 */} + void handlePickImage()} + helperText={ + uploadDiaryImage.isPending + ? "이미지를 업로드하는 중입니다. 잠시만 기다려주세요." + : uploadedImage + ? "이미지 업로드가 완료되었습니다." + : undefined + } + /> + + {/* 본문 입력 필드 */} + + + {/* 공개 여부 선택 */} + + setIsPublic(false)} + activeOpacity={0.7} + > + {!isPublic ? : } + 나만 보기 + + + setIsPublic(true)} + activeOpacity={0.7} + > + {isPublic ? : } + 공개하기 + + + + + ); +} + +const styles = StyleSheet.create({ + // 전체 배경 흰색 + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + paddingHorizontal: 20, + paddingTop: 24, + paddingBottom: 32, + gap: 24, + }, + // 날짜 + 제목 묶음 섹션 + titleSection: { + gap: 8, + paddingBottom: 24, + borderBottomWidth: 1, + borderBottomColor: "#EFEFEF", + }, + // 날짜 텍스트 + dateText: { + fontSize: 16, + color: "#282828", + fontWeight: "400", + lineHeight: 16 * 1.6, + }, + // 제목 입력 (박스 없이 큰 텍스트) + titleInput: { + fontSize: 24, + fontWeight: "600", + color: "#171717", + lineHeight: 24 * 1.35, + paddingVertical: 0, + paddingHorizontal: 0, + }, + // 본문 입력 (플레인 멀티라인) + contentInput: { + fontSize: 16, + fontWeight: "400", + color: "#171717", + lineHeight: 16 * 1.6, + minHeight: 80, + paddingVertical: 0, + paddingHorizontal: 0, + }, + // 공개 여부 선택 행 + visibilityRow: { + flexDirection: "row", + gap: 16, + paddingBottom: 8, + }, + visibilityOption: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + // 공개 여부 텍스트 (선택 여부 무관 검정) + visibilityLabel: { + fontSize: 14, + fontWeight: "400", + color: "#171717", + lineHeight: 14 * 1.6, + }, +}); diff --git a/src/pages/delivery/DeliveryCompleteScreen.tsx b/src/pages/delivery/DeliveryCompleteScreen.tsx new file mode 100644 index 0000000..c0f2765 --- /dev/null +++ b/src/pages/delivery/DeliveryCompleteScreen.tsx @@ -0,0 +1,111 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import type { RootStackScreenProps } from "@/navigation/types"; + +type Props = RootStackScreenProps<"DeliveryComplete">; + +const packagePlantImage = require("@/assets/images/plant.png"); + +export default function DeliveryCompleteScreen({ navigation }: Props) { + const handleDone = () => { + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }); + }; + + return ( + + + + + + + + + 배송 요청이 완료되었어요! + 3~7일 이내 자택으로 배송될 예정이에요. + 이제 텃밭을 열고, 새로운 식물을 키울 수 있어요. + + + + + + + + + + 다음 + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + progressTrack: { + marginHorizontal: 20, + marginTop: 16, + height: 4, + borderRadius: 999, + backgroundColor: "#F1F1F1", + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 999, + backgroundColor: "#6FCF4A", + }, + content: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 48, + justifyContent: "space-between", + }, + headerBlock: { + gap: 10, + }, + title: { + fontSize: 18, + lineHeight: 28, + fontWeight: "700", + color: "#171717", + }, + description: { + fontSize: 16, + lineHeight: 26, + color: "#171717", + }, + imageWrap: { + alignItems: "center", + justifyContent: "center", + paddingBottom: 48, + }, + image: { + width: 260, + height: 260, + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 20, + }, + primaryButton: { + minHeight: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#6FCF4A", + }, + primaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, +}); diff --git a/src/pages/delivery/DeliveryScreen.tsx b/src/pages/delivery/DeliveryScreen.tsx new file mode 100644 index 0000000..a2bd66d --- /dev/null +++ b/src/pages/delivery/DeliveryScreen.tsx @@ -0,0 +1,303 @@ +import { useMemo, useState } from "react"; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import DeliveryRequestSelector from "@/components/delivery/DeliveryRequestSelector"; +import DeliveryTextField from "@/components/delivery/DeliveryTextField"; +import { useCreateSeedDelivery, useUnlockGarden } from "@/hooks/delivery/useDeliveryApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import { useHomeSummaryStore } from "@/stores/useHomeSummaryStore"; + +type Props = RootStackScreenProps<"Delivery">; + +export default function DeliveryScreen({ navigation, route }: Props) { + const user = useHomeSummaryStore(state => state.user); + const gardens = useHomeSummaryStore(state => state.gardens); + const updateGarden = useHomeSummaryStore(state => state.updateGarden); + const setUser = useHomeSummaryStore(state => state.setUser); + const createSeedDelivery = useCreateSeedDelivery(); + const unlockGarden = useUnlockGarden(); + + const [recipientName, setRecipientName] = useState(user?.username ?? ""); + const [recipientPhone, setRecipientPhone] = useState(""); + const [postalCode, setPostalCode] = useState(""); + const [address, setAddress] = useState(""); + const [addressDetail, setAddressDetail] = useState(""); + const [message, setMessage] = useState(""); + const [customMessage, setCustomMessage] = useState(""); + + const seedType = route.params?.seedType; + const seedName = route.params?.seedName; + const gardenId = route.params?.gardenId; + const gardenSlotNumber = route.params?.gardenSlotNumber; + const selectedGarden = useMemo( + () => gardens.find(garden => garden.gardenId === gardenId) ?? null, + [gardenId, gardens] + ); + const resolvedMessage = message === "직접 입력" ? customMessage.trim() : message; + + const isFormValid = useMemo( + () => + Boolean( + seedType && + recipientName.trim() && + recipientPhone.trim() && + postalCode.trim() && + address.trim() && + addressDetail.trim() + ), + [address, addressDetail, postalCode, recipientName, recipientPhone, seedType] + ); + + const handleBack = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("UnlockGarden", { + gardenId, + gardenSlotNumber, + }); + }; + + const handleSubmit = async () => { + if (!seedType || !isFormValid || createSeedDelivery.isPending || unlockGarden.isPending) { + return; + } + + try { + await createSeedDelivery.mutateAsync({ + seedType, + recipientName: recipientName.trim(), + recipientPhone: recipientPhone.trim(), + postalCode: postalCode.trim(), + address: address.trim(), + addressDetail: addressDetail.trim(), + message: resolvedMessage || undefined, + }); + + await unlockGarden.mutateAsync(); + + if (selectedGarden) { + updateGarden(selectedGarden.gardenId, { + isLocked: false, + locked: false, + isUnlockable: false, + unlockable: false, + avatar: null, + }); + } + + if (user) { + setUser({ + ...user, + lastAccessedSlotNumber: gardenSlotNumber ?? user.lastAccessedSlotNumber, + }); + } + + navigation.replace("DeliveryComplete", { + seedName, + gardenId, + gardenSlotNumber, + }); + } catch (error) { + if (error instanceof Error) { + Alert.alert("진행 실패", error.message); + } + } + }; + + if (!seedType) { + return ( + + + navigation.replace("UnlockGarden", { gardenId, gardenSlotNumber })} + /> + + ); + } + + return ( + + + + + + + + + 배송 정보를 입력해주세요 + + + + 받는 분 정보 + + + + + + 배송지 정보 + + + + + + + + + + navigation.navigate("Main", { screen: "Home" })} + style={styles.secondaryButton} + > + 나중에 받기 + + void handleSubmit()} + style={[ + styles.primaryButton, + !isFormValid || createSeedDelivery.isPending || unlockGarden.isPending + ? styles.primaryButtonDisabled + : null, + ]} + > + + {createSeedDelivery.isPending || unlockGarden.isPending ? "진행 중..." : "다음"} + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + progressTrack: { + marginHorizontal: 20, + marginTop: 16, + height: 4, + borderRadius: 999, + backgroundColor: "#F1F1F1", + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 999, + backgroundColor: "#6FCF4A", + }, + content: { + paddingHorizontal: 20, + paddingTop: 48, + paddingBottom: 24, + gap: 36, + }, + headerBlock: { + gap: 12, + }, + title: { + fontSize: 18, + lineHeight: 28, + fontWeight: "700", + color: "#171717", + }, + section: { + gap: 18, + }, + sectionTitle: { + fontSize: 18, + lineHeight: 27, + fontWeight: "700", + color: "#171717", + }, + footer: { + flexDirection: "row", + gap: 12, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 20, + }, + secondaryButton: { + flex: 1, + minHeight: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#EFF9EA", + }, + secondaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#46C02B", + }, + primaryButton: { + flex: 1, + minHeight: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#6FCF4A", + }, + primaryButtonDisabled: { + backgroundColor: "#EAEAEA", + }, + primaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, + primaryButtonTextDisabled: { + color: "#BFBFBF", + }, +}); diff --git a/src/pages/delivery/UnlockGardenScreen.tsx b/src/pages/delivery/UnlockGardenScreen.tsx new file mode 100644 index 0000000..a0e502a --- /dev/null +++ b/src/pages/delivery/UnlockGardenScreen.tsx @@ -0,0 +1,272 @@ +import { useEffect, useMemo, useState } from "react"; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import PlantOptionCard from "@/components/delivery/PlantOptionCard"; +import { useDeliverablePlants } from "@/hooks/delivery/useDeliveryApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import { useHomeSummaryStore } from "@/stores/useHomeSummaryStore"; +import { getGardenLocked, getGardenUnlockable } from "@/types/home/garden"; + +type Props = RootStackScreenProps<"UnlockGarden">; + +export default function UnlockGardenScreen({ navigation, route }: Props) { + const { data: plants, error, isLoading, refetch } = useDeliverablePlants(); + const gardens = useHomeSummaryStore(state => state.gardens); + const [selectedSeedType, setSelectedSeedType] = useState(null); + + const selectedGarden = useMemo(() => { + if (route.params?.gardenId) { + const routedGarden = gardens.find(garden => garden.gardenId === route.params?.gardenId); + if (routedGarden && getGardenLocked(routedGarden)) { + return routedGarden; + } + } + + return gardens.find(garden => getGardenLocked(garden) && getGardenUnlockable(garden)) ?? null; + }, [gardens, route.params?.gardenId]); + + useEffect(() => { + if (!selectedSeedType && plants && plants.length > 0) { + setSelectedSeedType(plants[0].seedType); + } + }, [plants, selectedSeedType]); + + const selectedPlant = plants?.find(plant => plant.seedType === selectedSeedType) ?? null; + + const handleBack = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("Main", { screen: "Home" }); + }; + + if (isLoading) { + return ( + + + + + ); + } + + if (error) { + return ( + + + void refetch()} + /> + + ); + } + + if (!selectedGarden) { + return ( + + + + + ); + } + + if (!plants || plants.length === 0) { + return ( + + + + + ); + } + + return ( + + + + + + + + + 원하는 식물을 선택해주세요 + + 선택한 씨앗은 새로 열리는 텃밭 {selectedGarden.gardenSlotNumber}번으로 배송 요청됩니다. + + + + + {plants.map(plant => ( + setSelectedSeedType(plant.seedType)} + /> + ))} + + + {selectedPlant ? {selectedPlant.name} : null} + + + {plants.map(plant => ( + + ))} + + + + + navigation.navigate("Main", { screen: "Home" })} + style={styles.secondaryButton} + > + 나중에 받기 + + + navigation.navigate("Delivery", { + seedType: selectedPlant?.seedType, + seedName: selectedPlant?.name, + gardenId: selectedGarden.gardenId, + gardenSlotNumber: selectedGarden.gardenSlotNumber, + }) + } + style={[styles.primaryButton, !selectedPlant ? styles.primaryButtonDisabled : null]} + > + 다음 + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + progressTrack: { + marginHorizontal: 20, + marginTop: 16, + height: 4, + borderRadius: 999, + backgroundColor: "#F1F1F1", + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 999, + backgroundColor: "#6FCF4A", + }, + content: { + paddingHorizontal: 20, + paddingTop: 48, + paddingBottom: 24, + }, + headerBlock: { + gap: 12, + marginBottom: 40, + }, + title: { + fontSize: 18, + lineHeight: 28, + fontWeight: "700", + color: "#171717", + }, + description: { + fontSize: 14, + lineHeight: 22, + color: "#171717", + }, + carousel: { + gap: 18, + paddingHorizontal: 48, + }, + selectedName: { + marginTop: 18, + textAlign: "center", + fontSize: 18, + lineHeight: 27, + fontWeight: "700", + color: "#171717", + }, + pagination: { + marginTop: 12, + flexDirection: "row", + justifyContent: "center", + gap: 8, + }, + dot: { + width: 8, + height: 8, + borderRadius: 999, + }, + dotActive: { + backgroundColor: "#7A7A7A", + }, + dotInactive: { + backgroundColor: "#E2E2E2", + }, + footer: { + flexDirection: "row", + gap: 12, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 20, + }, + secondaryButton: { + flex: 1, + minHeight: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#EFF9EA", + }, + secondaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#46C02B", + }, + primaryButton: { + flex: 1, + minHeight: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#6FCF4A", + }, + primaryButtonDisabled: { + backgroundColor: "#EAEAEA", + }, + primaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, +}); diff --git a/src/pages/feed/FeedAvatarScreen.tsx b/src/pages/feed/FeedAvatarScreen.tsx index f634ae1..425162a 100644 --- a/src/pages/feed/FeedAvatarScreen.tsx +++ b/src/pages/feed/FeedAvatarScreen.tsx @@ -1,62 +1,160 @@ -import React, { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { - View, - Text, - TextInput, - TouchableOpacity, - ScrollView, + ActivityIndicator, + FlatList, KeyboardAvoidingView, Platform, + RefreshControl, StyleSheet, + Text, + View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import type { RootStackScreenProps } from "@/navigation/types"; - -import { LeftIcon, SendIcon } from "@/assets/icons/CommonIcons"; -import FeedDetail from "@/components/feed/FeedDetail"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import FeedInfiniteDetailItem from "@/components/feed/FeedInfiniteDetailItem"; import usePostComment from "@/hooks/comments/useCommentApi"; import { useAvatarPostDetail } from "@/hooks/feed/useAvatarPostDetailApi"; +import { useRandomFeedSession } from "@/hooks/feed/useRandomFeedSession"; import type { FeedDetailResult } from "@/types/feed/detail"; +import type { RandomFeedPostType } from "@/types/feed/randomFeedApi.type"; +import { createTimingLogger } from "@/utils/debug"; type Props = RootStackScreenProps<"FeedAvatar">; +type FeedListItem = { + key: string; + postId: number; + postType: RandomFeedPostType; + isSeed: boolean; +}; + export default function FeedAvatarScreen({ navigation, route }: Props) { const { postId } = route.params; const id = Number(postId); const isValidId = Number.isFinite(id) && id > 0; - const { data, refetch } = useAvatarPostDetail(isValidId ? id : 0); - + const { data, error, isLoading, isRefetching, refetch } = useAvatarPostDetail(isValidId ? id : 0); const [content, setContent] = useState(""); + const initialLoadTimingRef = useRef | null>(null); const { mutateAsync, isPending } = usePostComment(() => refetch()); + const { + data: randomSession, + isLoading: isRandomLoading, + isError: isRandomError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useRandomFeedSession({ + enabled: isValidId && Boolean(data), + sessionKey: `AVATAR_POST:${id}`, + size: 6, + }); + + useEffect(() => { + initialLoadTimingRef.current = createTimingLogger("FeedAvatarScreen", "initial detail load", { postId: id }); + }, [id]); + + useEffect(() => { + if (!data || !initialLoadTimingRef.current) { + return; + } - const handleBackClick = () => navigation.goBack(); + initialLoadTimingRef.current({ hasRandomSession: Boolean(randomSession) }); + initialLoadTimingRef.current = null; + }, [data, randomSession]); + + const handleBackClick = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("Main", { screen: "Feed" }); + }; const handleSend = async () => { if (!isValidId || !content.trim()) return; - await mutateAsync({ content, targetId: id, targetType: "AVATAR_POST" }); - setContent(""); + + try { + await mutateAsync({ content, targetId: id, targetType: "AVATAR_POST" }); + setContent(""); + } catch (commentError) { + console.error("[FeedAvatarScreen] Failed to post comment:", commentError); + } }; if (!isValidId) { return ( - - 잘못된 게시글 ID입니다. + + + + + ); + } + + if (isLoading) { + return ( + + void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + + + ); + } + + if (error) { + return ( + + void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + void refetch()} + /> ); } if (!data) { return ( - - - 로딩 중... - + + void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + ); } - const result: FeedDetailResult = { + const seedResult: FeedDetailResult = { id: data.id, writerId: data.writerId, writerName: data.writerName, @@ -72,52 +170,90 @@ export default function FeedAvatarScreen({ navigation, route }: Props) { isPublic: data.isPublic, }; + const seenKeys = new Set([`AVATAR_POST:${id}`]); + const listData: FeedListItem[] = [ + { + key: `seed-AVATAR_POST-${id}`, + postId: id, + postType: "AVATAR_POST", + isSeed: true, + }, + ]; + + for (const item of randomSession?.items ?? []) { + const itemKey = `${item.postType}:${item.postId}`; + if (seenKeys.has(itemKey)) { + continue; + } + seenKeys.add(itemKey); + listData.push({ + key: `random-${item.postType}-${item.postId}`, + postId: item.postId, + postType: item.postType, + isSeed: false, + }); + } + return ( - {/* 헤더 */} - - - - - 둘러보기 - - - - {/* 메인 콘텐츠 */} - void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + item.key} showsVerticalScrollIndicator={false} - > - - - - {/* 댓글 입력 */} - - - void refetch()} + tintColor="#7DC960" + /> + } + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }} + onEndReachedThreshold={0.45} + renderItem={({ item }) => ( + void handleSend() : undefined} + isCommentPending={item.isSeed ? isPending : false} /> - - - - - + )} + ListFooterComponent={ + + {isFetchingNextPage ? ( + + ) : null} + {!isFetchingNextPage && isRandomLoading ? ( + + ) : null} + {isRandomError ? ( + + 랜덤 피드를 이어서 불러오지 못했습니다. + + ) : null} + + } + /> ); @@ -131,61 +267,17 @@ const styles = StyleSheet.create({ keyboardView: { flex: 1, }, - header: { - height: 56, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 12, - borderBottomWidth: 1, - borderBottomColor: "#E5E7EB", - }, - headerTitle: { - fontSize: 18, - fontWeight: "700", - color: "#171717", - }, - headerSpacer: { - width: 24, - height: 24, - }, - scrollView: { - flex: 1, + listContent: { + paddingBottom: 24, }, - loadingContainer: { - flex: 1, - justifyContent: "center", + footer: { alignItems: "center", + justifyContent: "center", + paddingVertical: 20, + gap: 8, }, - loadingText: { - fontSize: 14, + footerText: { + fontSize: 13, color: "#6B7280", }, - errorText: { - fontSize: 14, - color: "#171717", - textAlign: "center", - marginTop: 20, - }, - commentInputContainer: { - borderTopWidth: 1, - borderTopColor: "#E5E7EB", - backgroundColor: "#FFFFFF", - paddingHorizontal: 20, - paddingVertical: 12, - }, - commentInputRow: { - flexDirection: "row", - alignItems: "center", - gap: 12, - }, - commentInput: { - flex: 1, - paddingHorizontal: 16, - paddingVertical: 10, - backgroundColor: "#E5E7EB", - borderRadius: 20, - fontSize: 14, - color: "#171717", - }, }); diff --git a/src/pages/feed/FeedDiaryScreen.tsx b/src/pages/feed/FeedDiaryScreen.tsx index 7a30572..737f3d9 100644 --- a/src/pages/feed/FeedDiaryScreen.tsx +++ b/src/pages/feed/FeedDiaryScreen.tsx @@ -1,62 +1,160 @@ -import React, { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { - View, - Text, - TextInput, - TouchableOpacity, - ScrollView, + ActivityIndicator, + FlatList, KeyboardAvoidingView, Platform, + RefreshControl, StyleSheet, + Text, + View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import type { RootStackScreenProps } from "@/navigation/types"; - -import { LeftIcon, SendIcon } from "@/assets/icons/CommonIcons"; -import FeedDetail from "@/components/feed/FeedDetail"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import FeedInfiniteDetailItem from "@/components/feed/FeedInfiniteDetailItem"; import usePostComment from "@/hooks/comments/useCommentApi"; +import { useRandomFeedSession } from "@/hooks/feed/useRandomFeedSession"; import { useDiaryDetail } from "@/hooks/log/useDiaryDetailApi"; import type { FeedDetailResult } from "@/types/feed/detail"; +import type { RandomFeedPostType } from "@/types/feed/randomFeedApi.type"; +import { createTimingLogger } from "@/utils/debug"; type Props = RootStackScreenProps<"FeedDiary">; +type FeedListItem = { + key: string; + postId: number; + postType: RandomFeedPostType; + isSeed: boolean; +}; + export default function FeedDiaryScreen({ navigation, route }: Props) { const { postId } = route.params; const id = Number(postId); const isValidId = Number.isFinite(id) && id > 0; - const { data, refetch } = useDiaryDetail(isValidId ? id : 0); - + const { data, error, isLoading, isRefetching, refetch } = useDiaryDetail(isValidId ? id : 0); const [content, setContent] = useState(""); + const initialLoadTimingRef = useRef | null>(null); const { mutateAsync, isPending } = usePostComment(() => refetch()); + const { + data: randomSession, + isLoading: isRandomLoading, + isError: isRandomError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useRandomFeedSession({ + enabled: isValidId && Boolean(data), + sessionKey: `DIARY:${id}`, + size: 6, + }); + + useEffect(() => { + initialLoadTimingRef.current = createTimingLogger("FeedDiaryScreen", "initial detail load", { postId: id }); + }, [id]); + + useEffect(() => { + if (!data || !initialLoadTimingRef.current) { + return; + } - const handleBackClick = () => navigation.goBack(); + initialLoadTimingRef.current({ hasRandomSession: Boolean(randomSession) }); + initialLoadTimingRef.current = null; + }, [data, randomSession]); + + const handleBackClick = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("Main", { screen: "Feed" }); + }; const handleSend = async () => { if (!isValidId || !content.trim()) return; - await mutateAsync({ content, targetId: id, targetType: "DIARY" }); - setContent(""); + + try { + await mutateAsync({ content, targetId: id, targetType: "DIARY" }); + setContent(""); + } catch (commentError) { + console.error("[FeedDiaryScreen] Failed to post comment:", commentError); + } }; if (!isValidId) { return ( - - 잘못된 게시글 ID입니다. + + + + + ); + } + + if (isLoading) { + return ( + + void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + + + ); + } + + if (error) { + return ( + + void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + void refetch()} + /> ); } if (!data) { return ( - - - 로딩 중... - + + void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + ); } - const result: FeedDetailResult = { + const seedResult: FeedDetailResult = { id: data.id, writerId: data.writerId, writerName: data.writerName, @@ -72,52 +170,88 @@ export default function FeedDiaryScreen({ navigation, route }: Props) { isPublic: data.isPublic, }; + const seenKeys = new Set([`DIARY:${id}`]); + const listData: FeedListItem[] = [ + { key: `seed-DIARY-${id}`, postId: id, postType: "DIARY", isSeed: true }, + ]; + + for (const item of randomSession?.items ?? []) { + const itemKey = `${item.postType}:${item.postId}`; + if (seenKeys.has(itemKey)) { + continue; + } + seenKeys.add(itemKey); + listData.push({ + key: `random-${item.postType}-${item.postId}`, + postId: item.postId, + postType: item.postType, + isSeed: false, + }); + } + return ( - {/* 헤더 */} - - - - - 둘러보기 - - - - {/* 메인 콘텐츠 */} - void refetch()} + rightActionDisabled={isLoading || isRefetching} + /> + item.key} showsVerticalScrollIndicator={false} - > - - - - {/* 댓글 입력 */} - - - void refetch()} + tintColor="#7DC960" + /> + } + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }} + onEndReachedThreshold={0.45} + renderItem={({ item }) => ( + void handleSend() : undefined} + isCommentPending={item.isSeed ? isPending : false} /> - - - - - + )} + ListFooterComponent={ + + {/* 한글 주석: + seed 포스트 아래 랜덤 카드가 이어 붙기 때문에, + 다음 페이지 로딩 상태만 하단에서 가볍게 노출한다. */} + {isFetchingNextPage ? ( + + ) : null} + {!isFetchingNextPage && isRandomLoading ? ( + + ) : null} + {isRandomError ? ( + + 랜덤 피드를 이어서 불러오지 못했습니다. + + ) : null} + + } + /> ); @@ -131,61 +265,17 @@ const styles = StyleSheet.create({ keyboardView: { flex: 1, }, - header: { - height: 56, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 12, - borderBottomWidth: 1, - borderBottomColor: "#E5E7EB", - }, - headerTitle: { - fontSize: 18, - fontWeight: "700", - color: "#171717", - }, - headerSpacer: { - width: 24, - height: 24, - }, - scrollView: { - flex: 1, + listContent: { + paddingBottom: 24, }, - loadingContainer: { - flex: 1, - justifyContent: "center", + footer: { alignItems: "center", + justifyContent: "center", + paddingVertical: 20, + gap: 8, }, - loadingText: { - fontSize: 14, + footerText: { + fontSize: 13, color: "#6B7280", }, - errorText: { - fontSize: 14, - color: "#171717", - textAlign: "center", - marginTop: 20, - }, - commentInputContainer: { - borderTopWidth: 1, - borderTopColor: "#E5E7EB", - backgroundColor: "#FFFFFF", - paddingHorizontal: 20, - paddingVertical: 12, - }, - commentInputRow: { - flexDirection: "row", - alignItems: "center", - gap: 12, - }, - commentInput: { - flex: 1, - paddingHorizontal: 16, - paddingVertical: 10, - backgroundColor: "#E5E7EB", - borderRadius: 20, - fontSize: 14, - color: "#171717", - }, }); diff --git a/src/pages/feed/FeedScreen.tsx b/src/pages/feed/FeedScreen.tsx index 4972183..0d6c9c5 100644 --- a/src/pages/feed/FeedScreen.tsx +++ b/src/pages/feed/FeedScreen.tsx @@ -1,22 +1,27 @@ -import React from "react"; +import React, { useCallback } from "react"; import { + RefreshControl, View, Text, TouchableOpacity, ScrollView, StyleSheet, + Image, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import type { MainTabScreenProps } from "@/navigation/types"; import { UserPlusIcon } from "@/assets/icons/CommonIcons"; +import StatusView from "@/components/common/StatusView"; import FeedList from "@/components/feed/FeedList"; import { useFeed } from "@/hooks/feed/useFeedApi"; +const refreshIcon = require("../../../assets/refresh-icon.png"); + type Props = MainTabScreenProps<"Feed">; export default function FeedScreen({ navigation }: Props) { - const { data: result, isLoading, error } = useFeed(); + const { data: result, isLoading, isRefetching, error, refetch } = useFeed(); const handleUserPlusClick = () => { navigation.navigate("Follow"); @@ -30,14 +35,29 @@ export default function FeedScreen({ navigation }: Props) { } }; - // API 결과가 null/빈 배열이면 빈 배열로 대체 + const handleRefetch = useCallback(() => { + void refetch(); + }, [refetch]); + const dataForRender = result && result.length > 0 ? result : []; return ( - {/* 헤더 */} - + + + 둘러보기 - {/* 둘러보기 사진 블록 */} - - void refetch()} /> - + ) : ( + + } + > + + + )} ); } @@ -73,20 +108,32 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingVertical: 12, - paddingHorizontal: 20, - }, - headerSpacer: { - width: 24, - marginLeft: 20, + paddingTop: 12, + paddingRight: 20, + paddingBottom: 12, + paddingLeft: 20, + marginBottom: 12, }, headerTitle: { fontSize: 18, fontWeight: "700", color: "#171717", }, + headerIconButton: { + width: 24, + alignItems: "center", + justifyContent: "center", + }, + refreshIcon: { + width: 22, + height: 22, + }, + refreshIconDisabled: { + opacity: 0.4, + }, headerButton: { - paddingRight: 0, + width: 24, + alignItems: "center", }, scrollView: { flex: 1, diff --git a/src/pages/follow/FollowScreen.tsx b/src/pages/follow/FollowScreen.tsx index 3e54aac..95c59ac 100644 --- a/src/pages/follow/FollowScreen.tsx +++ b/src/pages/follow/FollowScreen.tsx @@ -1,13 +1,251 @@ -import { View, Text } from "react-native"; -import type { MainTabScreenProps } from "@/navigation/types"; +import { useState } from "react"; +import { + Image, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { RootStackScreenProps } from "@/navigation/types"; +import { XmarkIcon } from "@/assets/icons/CommonIcons"; +import ConfirmModal from "@/components/common/ConfirmModal"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import { + useFollowers, + useFollowing, + useUnfollowUser, +} from "@/hooks/follow/useFollowApi"; +import useTokenStore from "@/stores/useTokenStore"; -type Props = MainTabScreenProps<"Follow">; +type Props = RootStackScreenProps<"Follow">; +type Tab = "added" | "followed"; export default function FollowScreen({ navigation }: Props) { + const [activeTab, setActiveTab] = useState("added"); + const [pendingRemoveUserId, setPendingRemoveUserId] = useState(null); + const userId = useTokenStore(state => state.userId); + const followingQuery = useFollowing(userId); + const followersQuery = useFollowers(userId); + const unfollowMutation = useUnfollowUser(userId); + + const isLoading = activeTab === "added" ? followingQuery.isLoading : followersQuery.isLoading; + const isRefetching = activeTab === "added" + ? followingQuery.isRefetching + : followersQuery.isRefetching; + const error = activeTab === "added" ? followingQuery.error : followersQuery.error; + const users = + activeTab === "added" + ? followingQuery.data ?? [] + : followersQuery.data ?? []; + + const handleBack = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("Main", { screen: "Feed" }); + }; + + const handleRetry = () => { + if (activeTab === "added") { + void followingQuery.refetch(); + return; + } + + void followersQuery.refetch(); + }; + + const handleConfirmRemoveFriend = (targetUserId: number) => { + if (unfollowMutation.isPending) { + return; + } + + setPendingRemoveUserId(targetUserId); + }; + + const handleRemoveFriend = async () => { + if (pendingRemoveUserId === null || unfollowMutation.isPending) { + return; + } + + const targetUserId = pendingRemoveUserId; + setPendingRemoveUserId(null); + await unfollowMutation.mutateAsync(targetUserId); + }; + return ( - - 팔로우 - 친구들의 정원 둘러보기 - + + + + + setActiveTab("added")} + > + + 내가 추가한 + + + setActiveTab("followed")} + > + + 나를 추가한 + + + + + {!userId ? ( + + ) : isLoading ? ( + + ) : error ? ( + + ) : users.length === 0 ? ( + + ) : ( + + } + > + {users.map(user => ( + + navigation.navigate("Profile", { userId: user.userId })} + > + + {user.userImageUrl ? ( + + ) : null} + + {user.username} + + {activeTab === "added" ? ( + handleConfirmRemoveFriend(user.userId)} + style={styles.removeButton} + > + + + ) : ( + + )} + + ))} + + )} + + setPendingRemoveUserId(null)} + onConfirm={() => void handleRemoveFriend()} + /> + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + tabRow: { + flexDirection: "row", + marginBottom: 24, + }, + tabButton: { + flex: 1, + alignItems: "center", + paddingVertical: 14, + borderBottomWidth: 2, + borderBottomColor: "transparent", + }, + tabButtonActive: { + borderBottomColor: "#7DC960", + }, + tabText: { + fontSize: 15, + color: "#9CA3AF", + }, + tabTextActive: { + color: "#171717", + fontWeight: "400", + }, + list: { + flex: 1, + }, + userRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 20, + paddingVertical: 20, + backgroundColor: "#FFFFFF", + }, + userInfo: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + avatarWrap: { + width: 40, + height: 40, + borderRadius: 20, + overflow: "hidden", + backgroundColor: "#E5E7EB", + }, + avatar: { + width: "100%", + height: "100%", + }, + username: { + fontSize: 15, + color: "#171717", + }, + removeButton: { + width: 24, + height: 24, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/src/pages/home/HomeScreen.tsx b/src/pages/home/HomeScreen.tsx index 51f22aa..f9877a6 100644 --- a/src/pages/home/HomeScreen.tsx +++ b/src/pages/home/HomeScreen.tsx @@ -1,13 +1,366 @@ -import { View, Text } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { useQueryClient } from "@tanstack/react-query"; +import { useFocusEffect } from "@react-navigation/native"; +import PagerView from "react-native-pager-view"; +import { SafeAreaView } from "react-native-safe-area-context"; +import HomeBottomSheet from "@/components/home/HomeBottomSheet"; +import HomeAlertsModal from "@/components/home/HomeAlertsModal"; +import HomeEmotionModal from "@/components/home/HomeEmotionModal"; +import HomeGardenScene from "@/components/home/HomeGardenScene"; +import HomeMapModal from "@/components/home/HomeMapModal"; +import HomeTrackingModal from "@/components/home/HomeTrackingModal"; +import StatusView from "@/components/common/StatusView"; +import useHomeApi, { + useHomePanelApi, + useTrackingPromptConfirm, + useTrackingPromptStatus, +} from "@/hooks/home/useHomeApi"; +import { useDailySurvey } from "@/hooks/mission/useMissionApi"; import type { MainTabScreenProps } from "@/navigation/types"; +import { + getEmotionSurveyCooldownActive, + useEmotionSurveyStore, +} from "@/stores/useEmotionSurveyStore"; +import { useHomeSummaryStore } from "@/stores/useHomeSummaryStore"; +import { + getGardenLocked, + type GardenSummary, + type HomeMissionType, + type TodayMission, +} from "@/types/home/garden"; +import type { SurveyAnswerKind } from "@/types/missions"; +import { createTimingLogger, debugLog, debugScreenMounted } from "@/utils/debug"; type Props = MainTabScreenProps<"Home">; +const backgrounds = [ + require("@/assets/images/background/background1.webp"), + require("@/assets/images/background/background2.webp"), + require("@/assets/images/background/background3.png"), + require("@/assets/images/background/background4.webp"), +] as const; + +type SceneItem = { + key: string; + slotNumber: number; + background: (typeof backgrounds)[number]; + garden: GardenSummary | null; +}; + export default function HomeScreen({ navigation }: Props) { + const queryClient = useQueryClient(); + const { data, error, isLoading, refetch } = useHomeApi(); + const { data: panel } = useHomePanelApi(); + const { + data: trackingPromptStatus, + refetch: refetchTrackingPromptStatus, + } = useTrackingPromptStatus(); + const trackingPromptConfirmMutation = useTrackingPromptConfirm(); + const surveyQuery = useDailySurvey(); + const { user, gardens, missions, todayDiaryId, hydrate } = useHomeSummaryStore(); + const { + lastAnsweredAt, + lastAnswerKind, + markAnswered, + resetIfExpired, + } = useEmotionSurveyStore(); + const [currentPage, setCurrentPage] = useState(0); + const [isSheetExpanded, setIsSheetExpanded] = useState(false); + const [isEmotionModalOpen, setIsEmotionModalOpen] = useState(false); + const [isAlertsModalOpen, setIsAlertsModalOpen] = useState(false); + const [isMapModalOpen, setIsMapModalOpen] = useState(false); + const [isTrackingModalOpen, setIsTrackingModalOpen] = useState(false); + const [emotionAnswerKind, setEmotionAnswerKind] = useState(null); + const openedTrackingCycleKeysRef = useRef>(new Set()); + const initialLoadTimingRef = useRef | null>(null); + + useEffect(() => { + debugScreenMounted("HomeScreen"); + initialLoadTimingRef.current = createTimingLogger("HomeScreen", "initial data load"); + }, []); + + useEffect(() => { + resetIfExpired(); + }, [lastAnsweredAt, resetIfExpired]); + + useFocusEffect( + useCallback(() => { + void refetch(); + void refetchTrackingPromptStatus(); + }, [refetch, refetchTrackingPromptStatus]) + ); + + useEffect(() => { + debugLog("HomeScreen", "query state changed", { + isLoading, + hasData: Boolean(data), + hasError: Boolean(error), + hasPanel: Boolean(panel), + trackingEligible: trackingPromptStatus?.eligible ?? false, + trackingCycleKey: trackingPromptStatus?.cycleKey ?? null, + }); + }, [data, error, isLoading, panel, trackingPromptStatus]); + + useEffect(() => { + if (data) { + debugLog("HomeScreen", "hydrate store from home api", { + missionCount: data.todayMissions?.length ?? 0, + gardenCount: data.gardenSummaries?.length ?? 0, + }); + hydrate(data); + } + }, [data, hydrate]); + + useEffect(() => { + if (!data || !initialLoadTimingRef.current) { + return; + } + + initialLoadTimingRef.current({ + gardenCount: data.gardenSummaries?.length ?? 0, + missionCount: data.todayMissions?.length ?? 0, + }); + initialLoadTimingRef.current = null; + }, [data]); + + useEffect(() => { + if (!trackingPromptStatus?.eligible || !trackingPromptStatus.cycleKey) { + return; + } + + if (openedTrackingCycleKeysRef.current.has(trackingPromptStatus.cycleKey)) { + return; + } + + openedTrackingCycleKeysRef.current.add(trackingPromptStatus.cycleKey); + setIsTrackingModalOpen(true); + }, [trackingPromptStatus]); + + const userInfo = data?.userInfo ?? user; + const gardenSummaries = data?.gardenSummaries ?? gardens; + const todayMissions = data?.todayMissions ?? missions; + const latestTodayDiaryId = data?.todayDiaryId ?? todayDiaryId; + const isEmotionCooldownActive = getEmotionSurveyCooldownActive(lastAnsweredAt); + + const scenes = useMemo( + () => + backgrounds.map((background, index) => ({ + key: `garden-scene-${index + 1}`, + slotNumber: index + 1, + background, + garden: + gardenSummaries.find(item => item.gardenSlotNumber === index + 1) ?? null, + })), + [gardenSummaries] + ); + + const currentScene = scenes[currentPage] ?? scenes[0] ?? null; + const isCurrentGardenLocked = currentScene?.garden ? getGardenLocked(currentScene.garden) : false; + + useEffect(() => { + if (isCurrentGardenLocked && isSheetExpanded) { + setIsSheetExpanded(false); + } + }, [isCurrentGardenLocked, isSheetExpanded]); + + const isEmotionAnswered = (surveyQuery.data?.isAnswered ?? false) || isEmotionCooldownActive; + const answeredKind = emotionAnswerKind ?? lastAnswerKind; + const initialPage = Math.max(0, Math.min(3, (userInfo?.lastAccessedSlotNumber ?? 1) - 1)); + + useEffect(() => { + setCurrentPage(initialPage); + }, [initialPage]); + + const handleTrackingPromptConfirm = useCallback(async () => { + if (!trackingPromptStatus?.cycleKey || trackingPromptConfirmMutation.isPending) { + return; + } + + try { + await trackingPromptConfirmMutation.mutateAsync({ + cycleKey: trackingPromptStatus.cycleKey, + }); + await queryClient.invalidateQueries({ queryKey: ["tracking-report-status"] }); + setIsTrackingModalOpen(false); + } catch (confirmError) { + debugLog("HomeScreen", "tracking prompt confirm failed", { confirmError }); + } + }, [queryClient, trackingPromptConfirmMutation, trackingPromptStatus?.cycleKey]); + + if (isLoading && gardenSummaries.length === 0) { + return ( + + + + ); + } + + if (error && gardenSummaries.length === 0) { + return ( + + void refetch()} + /> + + ); + } + return ( - - - 나풀나풀 메인 화면 + + { + const position = event.nativeEvent.position; + setCurrentPage(position); + void refetch(); + }} + > + {scenes.map(scene => ( + + setIsMapModalOpen(true)} + onPressBird={() => setIsAlertsModalOpen(true)} + onPressEmotion={() => setIsEmotionModalOpen(true)} + onPressUnlock={() => + navigation.navigate("UnlockGarden", { + gardenId: scene.garden?.gardenId, + gardenSlotNumber: scene.slotNumber, + }) + } + onPressEmpty={() => navigation.navigate("RegistrationAvatar", { entry: "garden" })} + /> + + ))} + + + {!isCurrentGardenLocked ? ( + + + {scenes.map((scene, index) => ( + + ))} + + + ) : null} + + {!isCurrentGardenLocked ? ( + { + if (mission.missionType === "DIARY" && mission.isCompleted && latestTodayDiaryId) { + navigation.navigate("LogDetail", { id: latestTodayDiaryId }); + return; + } + + const routeName = getMissionRouteName(mission); + if (routeName) { + navigation.navigate(routeName as never); + } + }} + onPressEmotionCheck={() => setIsEmotionModalOpen(true)} + /> + ) : null} + + setIsEmotionModalOpen(false)} + onAnswered={answer => { + setEmotionAnswerKind(answer); + markAnswered(answer); + setIsEmotionModalOpen(false); + }} + /> + setIsAlertsModalOpen(false)} + /> + setIsMapModalOpen(false)} + /> + void handleTrackingPromptConfirm()} + /> ); } + +function getMissionRouteName(mission: TodayMission) { + switch (mission.missionType as HomeMissionType) { + case "DIARY": + return "DailyMissionWriteDiary" as const; + case "QUIZ": + return "DailyMissionQuizMultipleChoice" as const; + case "CHECKING": + return "DailyMissionChecking" as const; + default: + return null; + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#DDE8D6", + }, + pager: { + flex: 1, + }, + page: { + flex: 1, + }, + paginationWrap: { + position: "absolute", + left: 0, + right: 0, + bottom: 156, + alignItems: "center", + }, + pagination: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + dot: { + width: 8, + height: 8, + borderRadius: 999, + }, + dotActive: { + backgroundColor: "#FFFFFF", + }, + dotInactive: { + backgroundColor: "rgba(124,124,124,0.7)", + }, + loadingContainer: { + flex: 1, + backgroundColor: "#F4F7F0", + }, +}); + + diff --git a/src/pages/log/LogDetailScreen.tsx b/src/pages/log/LogDetailScreen.tsx new file mode 100644 index 0000000..25f534f --- /dev/null +++ b/src/pages/log/LogDetailScreen.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { RootStackScreenProps } from "@/navigation/types"; +import CommentComposer from "@/components/common/CommentComposer"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import MyDiaryDetail from "@/components/log/MyDiaryDetail"; +import usePostComment from "@/hooks/comments/useCommentApi"; +import { useDiaryDetail } from "@/hooks/log/useDiaryDetailApi"; + +type Props = RootStackScreenProps<"LogDetail">; + +export default function LogDetailScreen({ navigation, route }: Props) { + const diaryId = Number(route.params.id); + const isValidId = Number.isFinite(diaryId) && diaryId > 0; + const { + data, + error, + isLoading, + refetch, + } = useDiaryDetail(isValidId ? diaryId : 0); + const [content, setContent] = useState(""); + const { mutateAsync, isPending } = usePostComment(() => refetch()); + + const handleBack = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("Main", { screen: "Log" }); + }; + + const handleSend = async () => { + if (!isValidId || !content.trim()) return; + + try { + await mutateAsync({ content, targetId: diaryId, targetType: "DIARY" }); + setContent(""); + } catch (commentError) { + console.error("[LogDetailScreen] Failed to post comment:", commentError); + } + }; + + let body; + + if (!isValidId) { + body = ( + + ); + } else if (isLoading) { + body = ; + } else if (error) { + body = ( + void refetch()} + /> + ); + } else if (!data) { + body = ( + + ); + } else { + body = ( + <> + + + + void handleSend()} + disabled={isPending} + /> + + ); + } + + return ( + + + + {body} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + keyboardView: { + flex: 1, + }, + scrollView: { + flex: 1, + }, +}); diff --git a/src/pages/log/LogScreen.tsx b/src/pages/log/LogScreen.tsx index b1e279d..b78e116 100644 --- a/src/pages/log/LogScreen.tsx +++ b/src/pages/log/LogScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { View, Text, @@ -9,7 +9,9 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useQueryClient } from "@tanstack/react-query"; import type { RootStackParamList } from "@/navigation/types"; +import { getDiaries } from "@/apis/log/diariesApi"; import LogCalendar from "@/components/log/LogCalendar"; import MyDiary from "@/components/log/MyDiary"; @@ -19,6 +21,22 @@ type NavigationProp = NativeStackNavigationProp; export default function LogScreen() { const [activeTab, setActiveTab] = useState("mission"); const navigation = useNavigation(); + const queryClient = useQueryClient(); + + useEffect(() => { + /* + * 한글 주석: + * 로그 화면 진입 시 현재 탭과 무관하게 일기 데이터를 미리 가져온다. + * 탭 전환 즉시 데이터가 준비돼 있어 로딩 지연이 없다. + */ + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + void queryClient.prefetchQuery({ + queryKey: ["diaries", year, month], + queryFn: () => getDiaries(year, month), + }); + }, [queryClient]); const handleDiarySelect = (diaryId: number) => { navigation.navigate("LogDetail", { id: diaryId }); diff --git a/src/pages/onboarding/OnboardingScreen.tsx b/src/pages/onboarding/OnboardingScreen.tsx index d0baad9..b3aea28 100644 --- a/src/pages/onboarding/OnboardingScreen.tsx +++ b/src/pages/onboarding/OnboardingScreen.tsx @@ -1,124 +1,120 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import { View, Text, - Image, StyleSheet, - Dimensions, TouchableOpacity, - FlatList, - NativeSyntheticEvent, - NativeScrollEvent, + Alert, } from "react-native"; import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { RootStackParamList } from "@/navigation/types"; import Splash from "@/components/common/Splash"; - -const { width } = Dimensions.get("window"); - -const onboardingData = [ - { - id: 1, - image: require("@/assets/images/onboarding/onboarding1.png"), - title: "나만의 식물을 화면 속에서 만나보세요", - subtitle: "물과 햇빛을 주며 직접 키울 수 있어요", - }, - { - id: 2, - image: require("@/assets/images/onboarding/onboarding2.png"), - title: "실제 식물을 배송받아 키워보세요", - subtitle: "식물 아바타에 해당하는 실제 식물을 키울 수 있어요", - }, - { - id: 3, - image: require("@/assets/images/onboarding/onboarding3.png"), - title: "미션을 통해 나무 레벨을 올리고\n새로운 식물을 키울 수 있어요", - subtitle: "미션 기록은 키움일지에 기록돼요", - }, - { - id: 4, - image: require("@/assets/images/onboarding/onboarding4.png"), - title: "다른 친구들의 이야기를 들을 수 있어요", - subtitle: "둘러보기로 친구를 만들고 방명록을 남겨보세요", - }, -]; +import OnboardingCarousel from "@/components/onboarding/OnboardingCarousel"; +import { useSupabaseOAuth } from "@/hooks/auth/useSupabaseOAuth"; +import useRegistrationStore from "@/stores/useRegistrationStore"; +import { debugLog, debugScreenMounted } from "@/utils/debug"; type NavigationProp = NativeStackNavigationProp; export default function OnboardingScreen() { const [isSplash, setIsSplash] = useState(true); - const [currentIndex, setCurrentIndex] = useState(0); - const flatListRef = useRef(null); const navigation = useNavigation(); - - const isLastSlide = currentIndex === onboardingData.length - 1; + const { performOAuth, isLoading, isExpoGo } = useSupabaseOAuth(); + const resetRegistration = useRegistrationStore(state => state.reset); useEffect(() => { + debugScreenMounted("OnboardingScreen"); const timer = setTimeout(() => { + debugLog("OnboardingScreen", "Splash timer finished"); setIsSplash(false); }, 2000); return () => clearTimeout(timer); }, []); - const handleScroll = (event: NativeSyntheticEvent) => { - const contentOffsetX = event.nativeEvent.contentOffset.x; - const index = Math.round(contentOffsetX / width); - setCurrentIndex(index); - }; - const handleStart = () => { + debugLog("OnboardingScreen", "Navigate -> Register"); navigation.navigate("Register"); }; + const handleOAuthLogin = async (provider: "kakao" | "google") => { + debugLog("OnboardingScreen", "OAuth button pressed", { provider }); + + if (provider === "kakao" && isExpoGo) { + debugLog("OnboardingScreen", "Blocked Kakao login in Expo Go"); + Alert.alert( + "카카오 로그인은 Expo Go에서 지원하지 않아요", + "카카오톡 앱 전환 때문에 인증이 초기화될 수 있어요. 카카오 로그인은 development build에서 테스트해주세요." + ); + return; + } + + const result = await performOAuth(provider); + debugLog("OnboardingScreen", "OAuth result received", { + provider, + success: result?.success, + isNewUser: result?.isNewUser, + requiresNicknameSetup: result?.requiresNicknameSetup, + cancelled: result?.cancelled, + }); + + if (result?.success && (result.requiresNicknameSetup || result.isNewUser)) { + resetRegistration(); + debugLog("OnboardingScreen", "Reset -> SocialNickname for nickname-incomplete social user"); + navigation.reset({ + index: 0, + routes: [ + { + name: "SocialNickname", + params: { initialNickname: result.nickname }, + }, + ], + }); + } + }; + if (isSplash) return ; return ( - item.id.toString()} - renderItem={({ item }) => ( - - - - {item.title} - {item.subtitle} - - - )} - /> + - {/* Pagination Dots */} - - {onboardingData.map((_, index) => ( - + + - ))} - + onPress={() => handleOAuthLogin("kakao")} + disabled={isLoading || isExpoGo} + > + + {isLoading ? "처리 중..." : "카카오로 시작하기"} + + - {/* Button */} - - - - 나만의 화단 만들러 가기 - - + {isExpoGo ? ( + + Expo Go에서는 카카오 로그인 대신 구글 로그인 또는 development build를 사용해주세요. + + ) : null} + + handleOAuthLogin("google")} + disabled={isLoading} + > + + {isLoading ? "처리 중..." : "구글로 시작하기"} + + + + + 비회원으로 화단 만들기 + + ); @@ -129,70 +125,54 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: "#FFFFFF", }, - slide: { - width, - flex: 1, - alignItems: "center", - justifyContent: "center", + buttonContainer: { paddingHorizontal: 32, - gap: 48, + paddingBottom: 32, }, - image: { - width: 256, - height: 256, + socialAuthContainer: { + gap: 12, }, - textContainer: { + socialButton: { + paddingVertical: 16, + borderRadius: 12, alignItems: "center", - gap: 8, - }, - title: { - fontSize: 20, - fontWeight: "bold", - color: "#000000", - textAlign: "center", }, - subtitle: { - fontSize: 14, - color: "#4B5563", - textAlign: "center", + kakaoButton: { + backgroundColor: "#FEE500", }, - pagination: { - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - gap: 8, - marginBottom: 80, - }, - dot: { - width: 8, - height: 8, - borderRadius: 4, + kakaoButtonText: { + color: "#000000", + fontSize: 16, + fontWeight: "600", }, - dotActive: { - backgroundColor: "#4CAF50", + googleButton: { + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#E5E7EB", }, - dotInactive: { - backgroundColor: "#D1D5DB", + googleButtonText: { + color: "#000000", + fontSize: 16, + fontWeight: "600", }, - buttonContainer: { - paddingHorizontal: 32, - paddingBottom: 32, + buttonDisabled: { + backgroundColor: "#E5E7EB", }, - button: { - backgroundColor: "#4CAF50", + guestButton: { paddingVertical: 16, - borderRadius: 12, alignItems: "center", + marginTop: 4, }, - buttonDisabled: { - backgroundColor: "#E5E7EB", - }, - buttonText: { - color: "#FFFFFF", - fontSize: 16, - fontWeight: "600", + guestButtonText: { + color: "#6B7280", + fontSize: 14, + textDecorationLine: "underline", }, - buttonTextDisabled: { - color: "#9CA3AF", + helperText: { + color: "#6B7280", + fontSize: 12, + lineHeight: 18, + textAlign: "center", + marginTop: -4, }, }); diff --git a/src/pages/option/AvatarNicknameEditScreen.tsx b/src/pages/option/AvatarNicknameEditScreen.tsx new file mode 100644 index 0000000..a0be2bf --- /dev/null +++ b/src/pages/option/AvatarNicknameEditScreen.tsx @@ -0,0 +1,275 @@ +import { + FlatList, + Image, + StyleSheet, + Text, + TouchableOpacity, + View, + useWindowDimensions, +} from "react-native"; +import { useMemo, useRef, useState } from "react"; + +import type { GardenSummary } from "@/types/home/garden"; +import { LeftIcon } from "@/assets/icons/CommonIcons"; +import type { RootStackScreenProps } from "@/navigation/types"; +import { SafeAreaView } from "react-native-safe-area-context"; +import StatusView from "@/components/common/StatusView"; +import useHomeApi from "@/hooks/home/useHomeApi"; + +type Props = RootStackScreenProps<"AvatarNicknameEdit">; + +type SelectableAvatar = { + avatarId: number; + avatarName: string; + avatarImageUrl: string; +}; + +// 캐러셀 아이템 너비 및 아이템 간 간격 +const ITEM_WIDTH = 258; +const ITEM_GAP = 16; + +export default function AvatarNicknameEditScreen({ navigation }: Props) { + const { width: screenWidth } = useWindowDimensions(); + const { data, isLoading, error, refetch } = useHomeApi(); + const [currentIndex, setCurrentIndex] = useState(0); + const flatListRef = useRef(null); + + // 캐러셀 아이템이 화면 중앙에 오도록 좌우 패딩 계산 + const sidePadding = (screenWidth - ITEM_WIDTH) / 2; + + // 아바타가 있는 정원 목록만 필터링 + const avatars = useMemo(() => { + if (!data?.gardenSummaries) return []; + return data.gardenSummaries + .filter((g: GardenSummary) => Boolean(g.avatar?.avatarId)) + .map((g: GardenSummary) => ({ + avatarId: g.avatar!.avatarId, + avatarName: g.avatar!.avatarName, + avatarImageUrl: g.avatar!.avatarImageUrl, + })); + }, [data]); + + const selectedAvatar = avatars[currentIndex] ?? null; + const isButtonEnabled = selectedAvatar !== null; + + const handleBack = () => navigation.goBack(); + + // 선택한 아바타 정보를 다음 단계(닉네임 입력)로 전달 + const handleNext = () => { + if (!selectedAvatar) return; + navigation.navigate("AvatarNicknameEditStep2", { + avatarId: selectedAvatar.avatarId, + avatarName: selectedAvatar.avatarName, + avatarImageUrl: selectedAvatar.avatarImageUrl, + }); + }; + + // --- 로딩 / 에러 / 빈 상태 처리 --- + if (isLoading) { + return ( + + + + ); + } + + if (error || !data) { + return ( + + void refetch()} + /> + + ); + } + + if (avatars.length === 0) { + return ( + + + + ); + } + + return ( + + {/* 상단 헤더 */} + + + + + 아바타 닉네임 변경 + + + + 변경할 식물을 선택해주세요 + + {/* 아바타 캐러셀 + 이름 + 페이지 점 */} + + String(item.avatarId)} + horizontal + showsHorizontalScrollIndicator={false} + snapToInterval={ITEM_WIDTH + ITEM_GAP} + decelerationRate="fast" + contentContainerStyle={{ paddingHorizontal: sidePadding }} + ItemSeparatorComponent={() => } + onMomentumScrollEnd={e => { + // 스크롤 위치로 현재 선택 인덱스 갱신 + const index = Math.round(e.nativeEvent.contentOffset.x / (ITEM_WIDTH + ITEM_GAP)); + setCurrentIndex(Math.max(0, Math.min(index, avatars.length - 1))); + }} + renderItem={({ item, index }) => { + const isSelected = index === currentIndex; + return ( + + + + ); + }} + /> + {/* 선택된 아바타 이름 */} + {selectedAvatar?.avatarName ?? ""} + {/* 페이지 인디케이터 점 */} + + {avatars.map((_, i) => ( + + ))} + + + + {/* 하단 다음 버튼 */} + + + 다음 + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + // 상단 헤더 영역 + header: { + height: 56, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 13, + borderBottomWidth: 1, + borderBottomColor: "#EFEFEF", + }, + backButton: { + width: 44, + height: 44, + justifyContent: "center", + }, + headerTitle: { + fontSize: 18, + fontWeight: "600", + color: "#171717", + }, + // 안내 타이틀 + title: { + fontSize: 20, + fontWeight: "600", + color: "#171717", + marginTop: 32, + marginLeft: 25, + }, + // 캐러셀 + 이름 + 점 묶음 영역 + // paddingTop으로 이미지 상단 여백 조정 (수동 조정 가능) + carouselWrapper: { + flex: 1, + justifyContent: "flex-start", + alignItems: "center", + gap: 16, + paddingTop: 160, + }, + // 개별 아바타 카드 + carouselItem: { + width: ITEM_WIDTH, + height: 292, + borderRadius: 12, + borderWidth: 2, + borderColor: "#72D14E", + overflow: "hidden", + }, + carouselItemSelected: { + backgroundColor: "#EEF9EA", + opacity: 1, + }, + carouselItemDimmed: { + opacity: 0.5, + }, + carouselImage: { + width: "100%", + height: "100%", + }, + // 아바타 이름 텍스트 + carouselName: { + fontSize: 18, + fontWeight: "600", + color: "#171717", + textAlign: "center", + }, + // 페이지 인디케이터 점 행 + dotRow: { + flexDirection: "row", + gap: 12, + marginBottom: 100, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: "#BFBFBF", + }, + dotActive: { + backgroundColor: "#171717", + }, + // 하단 버튼 영역 + footer: { + paddingHorizontal: 20, + paddingBottom: 16, + }, + button: { + height: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }, + buttonActive: { + backgroundColor: "#72D14E", + }, + buttonDisabled: { + backgroundColor: "#7C7C7C", + }, + buttonText: { + fontSize: 18, + fontWeight: "600", + color: "#FFFFFF", + }, +}); diff --git a/src/pages/option/AvatarNicknameEditStep2Screen.tsx b/src/pages/option/AvatarNicknameEditStep2Screen.tsx new file mode 100644 index 0000000..48ff498 --- /dev/null +++ b/src/pages/option/AvatarNicknameEditStep2Screen.tsx @@ -0,0 +1,170 @@ +import { useState } from "react"; +import { + Alert, + Image, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { RootStackScreenProps } from "@/navigation/types"; +import { LeftIcon } from "@/assets/icons/CommonIcons"; +import { EditIcon } from "@/assets/icons/CommonIcons"; +import { useUpdateAvatarNickname } from "@/hooks/option/useAvatarNicknameApi"; + +type Props = RootStackScreenProps<"AvatarNicknameEditStep2">; + +export default function AvatarNicknameEditStep2Screen({ navigation, route }: Props) { + const { avatarId, avatarName, avatarImageUrl } = route.params; + const updateAvatarNickname = useUpdateAvatarNickname(); + const [draftName, setDraftName] = useState(avatarName); + + const trimmed = draftName.trim(); + const isValid = trimmed.length >= 1 && trimmed.length <= 6; + const isChanged = trimmed !== avatarName; + const isButtonEnabled = isValid && isChanged && !updateAvatarNickname.isPending; + + const handleBack = () => navigation.goBack(); + + const handleSubmit = async () => { + if (!isButtonEnabled) return; + try { + await updateAvatarNickname.mutateAsync({ avatarId, newAvatarName: trimmed }); + navigation.pop(2); + } catch { + Alert.alert("아바타 닉네임 변경에 실패했습니다", "잠시 후 다시 시도해주세요."); + } + }; + + return ( + + + + + + 아바타 닉네임 변경 + + + + + + + + + + + + + + + + void handleSubmit()} + disabled={!isButtonEnabled} + activeOpacity={0.85} + > + + {updateAvatarNickname.isPending ? "처리 중..." : "확인"} + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + header: { + height: 56, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 13, + borderBottomWidth: 1, + borderBottomColor: "#EFEFEF", + }, + backButton: { + width: 44, + height: 44, + justifyContent: "center", + }, + headerTitle: { + fontSize: 18, + fontWeight: "600", + color: "#171717", + }, + content: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 16, + paddingHorizontal: 20, + }, + avatarCard: { + width: 258, + height: 292, + borderRadius: 12, + borderWidth: 2, + borderColor: "#72D14E", + backgroundColor: "#EEF9EA", + overflow: "hidden", + }, + avatarImage: { + width: "100%", + height: "100%", + }, + inputRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + height: 60, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: "#171717", + borderRadius: 8, + width: "100%", + }, + input: { + flex: 1, + fontSize: 16, + color: "#171717", + paddingVertical: 0, + }, + footer: { + paddingHorizontal: 20, + paddingBottom: 16, + }, + button: { + height: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }, + buttonActive: { + backgroundColor: "#72D14E", + }, + buttonDisabled: { + backgroundColor: "#EFEFEF", + }, + buttonText: { + fontSize: 18, + fontWeight: "600", + color: "#FFFFFF", + }, + buttonTextDisabled: { + color: "#BFBFBF", + }, +}); diff --git a/src/pages/option/OptionScreen.tsx b/src/pages/option/OptionScreen.tsx index dfdf610..ff19f60 100644 --- a/src/pages/option/OptionScreen.tsx +++ b/src/pages/option/OptionScreen.tsx @@ -1,13 +1,250 @@ -import { View, Text } from "react-native"; +import { useState, type ReactNode } from "react"; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import type { MainTabScreenProps } from "@/navigation/types"; +import { + RightIcon, + ToggleOffIcon, + ToggleOnIcon, +} from "@/assets/icons/CommonIcons"; +import ConfirmModal from "@/components/common/ConfirmModal"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import { useNotificationSettings, useUpdateNotificationSettings } from "@/hooks/option/useNotificationApi"; +import { useDeleteAccount } from "@/hooks/option/useDeleteAccount"; +import useTokenStore from "@/stores/useTokenStore"; +import { logout } from "@/utils/auth"; +import { registerDeviceFcmToken, unregisterDeviceFcmToken } from "@/utils/fcm"; type Props = MainTabScreenProps<"Option">; export default function OptionScreen({ navigation }: Props) { + const [isLoggingOut, setIsLoggingOut] = useState(false); + const [isLogoutConfirmVisible, setIsLogoutConfirmVisible] = useState(false); + const [isDeleteConfirmVisible, setIsDeleteConfirmVisible] = useState(false); + const { accessToken, userId, hasHydrated } = useTokenStore(); + const { data: notificationSettings } = useNotificationSettings(); + const updateSettingsMutation = useUpdateNotificationSettings(); + const deleteAccountMutation = useDeleteAccount(); + const pushNotification = notificationSettings?.notificationEnabled ?? true; + const marketingConsent = notificationSettings?.marketingConsent ?? false; + + const handleTogglePushNotification = async () => { + if (updateSettingsMutation.isPending) { + return; + } + + if (!pushNotification) { + const registered = await registerDeviceFcmToken(); + if (!registered) { + Alert.alert("알림 권한 필요", "기기 알림 권한을 허용한 뒤 다시 시도해주세요."); + return; + } + } else { + const removed = await unregisterDeviceFcmToken(); + if (!removed) { + Alert.alert("알림 설정 변경 실패", "토큰 해제에 실패했습니다. 잠시 후 다시 시도해주세요."); + return; + } + } + + updateSettingsMutation.mutate( + { + notificationEnabled: !pushNotification, + marketingConsent, + }, + { + onError: () => { + Alert.alert("알림 설정 변경 실패", "잠시 후 다시 시도해주세요."); + }, + } + ); + }; + + const handleLogout = () => { + if (isLoggingOut || !accessToken) { + return; + } + + setIsLogoutConfirmVisible(true); + }; + + const handleDeleteAccount = () => { + if (deleteAccountMutation.isPending || !accessToken) { + return; + } + + setIsDeleteConfirmVisible(true); + }; + + const handleConfirmDeleteAccount = () => { + if (deleteAccountMutation.isPending || !accessToken) { + return; + } + + setIsDeleteConfirmVisible(false); + deleteAccountMutation.mutate(undefined, { + onError: () => { + Alert.alert("회원 탈퇴 실패", "회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해주세요."); + }, + }); + }; + + const handleConfirmLogout = async () => { + if (isLoggingOut || !accessToken) { + return; + } + + setIsLogoutConfirmVisible(false); + setIsLoggingOut(true); + await logout(); + }; + + const loginStatus = !hasHydrated ? "세션 확인 중" : accessToken ? "로그인 상태" : "로그아웃 상태"; + return ( - - 설정 - 앱 설정 및 계정 관리 + + + + void handleTogglePushNotification()} + activeOpacity={0.7} + disabled={updateSettingsMutation.isPending} + accessibilityRole="switch" + accessibilityState={{ checked: pushNotification }} + > + {pushNotification ? : } + + } + /> + navigation.navigate("UserNicknameEdit")} /> + navigation.navigate("AvatarNicknameEdit")} /> + navigation.navigate("Policy")} /> + navigation.navigate("ServiceGuide")} /> + + + + + {loginStatus} + {userId ? `사용자 ID ${userId}` : "사용자 ID 없음"} + + + + setIsLogoutConfirmVisible(false)} + onConfirm={() => void handleConfirmLogout()} + /> + setIsDeleteConfirmVisible(false)} + onConfirm={handleConfirmDeleteAccount} + /> + + ); +} + +function OptionRow({ + label, + rightSlot, + danger = false, + disabled = false, + onPress, +}: { + label: string; + rightSlot?: ReactNode; + danger?: boolean; + disabled?: boolean; + onPress?: () => void; +}) { + const content = ( + + + {label} + + {rightSlot ?? } ); + + if (!onPress && !rightSlot) { + return content; + } + + if (!onPress) { + return content; + } + + return ( + + {content} + + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + scrollView: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + paddingHorizontal: 20, + paddingTop: 4, + paddingBottom: 24, + }, + row: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 16, + }, + label: { + fontSize: 14, + color: "#171717", + }, + labelDanger: { + color: "#EF4444", + }, + labelDisabled: { + color: "#D1D5DB", + }, + metaBlock: { + marginTop: 20, + gap: 6, + }, + metaText: { + fontSize: 12, + color: "#9CA3AF", + }, +}); diff --git a/src/pages/option/PolicyScreen.tsx b/src/pages/option/PolicyScreen.tsx new file mode 100644 index 0000000..2902449 --- /dev/null +++ b/src/pages/option/PolicyScreen.tsx @@ -0,0 +1,90 @@ +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { RootStackScreenProps } from "@/navigation/types"; +import { usePolicy } from "@/hooks/option/usePolicyApi"; + +type Props = RootStackScreenProps<"Policy">; + +const POLICY_TEXT = `제1조 (목적) +이 약관은 나풀나풀이가 제공하는 반려식물 아바타 키우기 서비스(이하 "서비스")의 이용과 관련하여 회사와 회원 간의 권리, 의무 및 책임 사항을 규정함을 목적으로 합니다. + +제2조 (정의) +"회원"이라 함은 본 약관에 동의하고 회사가 제공하는 서비스를 이용하는 자를 말합니다. +"아바타 식물"이라 함은 회원이 앱 내에서 돌보고 성장시키는 가상의 식물을 의미합니다. +"콘텐츠"라 함은 서비스 내에서 제공되는 이미지, 텍스트, 데이터 등을 말합니다. + +제3조 (약관의 효력 및 변경) +본 약관은 회원이 동의함과 동시에 효력이 발생합니다. +회사는 필요 시 관련 법령을 위배하지 않는 범위 내에서 약관을 변경할 수 있습니다.`; + +export default function PolicyScreen({ navigation }: Props) { + const { data } = usePolicy(); + const policyText = data?.trim() ? data : POLICY_TEXT; + + const handleBack = () => { + navigation.goBack(); + }; + + return ( + + + + 뒤로 + + 이용 약관 + + + + + + {policyText} + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#F7F8F4", + }, + header: { + height: 56, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + backgroundColor: "#FFFFFF", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#E5E7EB", + }, + sideButton: { + width: 56, + height: 44, + justifyContent: "center", + }, + backText: { + fontSize: 14, + fontWeight: "600", + color: "#374151", + }, + headerTitle: { + fontSize: 17, + fontWeight: "700", + color: "#171717", + }, + content: { + padding: 20, + }, + card: { + borderRadius: 22, + backgroundColor: "#FFFFFF", + padding: 20, + }, + body: { + fontSize: 14, + lineHeight: 24, + color: "#374151", + }, +}); diff --git a/src/pages/option/ServiceGuideScreen.tsx b/src/pages/option/ServiceGuideScreen.tsx new file mode 100644 index 0000000..374db3d --- /dev/null +++ b/src/pages/option/ServiceGuideScreen.tsx @@ -0,0 +1,63 @@ +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import LeftIcon from "@/assets/icons/common/left.svg"; +import type { RootStackScreenProps } from "@/navigation/types"; +import OnboardingCarousel from "@/components/onboarding/OnboardingCarousel"; + +type Props = RootStackScreenProps<"ServiceGuide">; + +export default function ServiceGuideScreen({ navigation }: Props) { + return ( + + + navigation.goBack()} + activeOpacity={0.7} + style={styles.headerSideButton} + > + + + 서비스 안내 + + + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + headerBar: { + height: 56, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 13, + backgroundColor: "#FFFFFF", + borderBottomWidth: 1, + borderBottomColor: "#EFEFEF", + }, + headerSideButton: { + width: 24, + height: 24, + justifyContent: "center", + alignItems: "center", + }, + headerTitle: { + flex: 1, + textAlign: "center", + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#171717", + }, + carouselWrap: { + flex: 1, + }, +}); diff --git a/src/pages/option/UserNicknameEditScreen.tsx b/src/pages/option/UserNicknameEditScreen.tsx new file mode 100644 index 0000000..603ff5c --- /dev/null +++ b/src/pages/option/UserNicknameEditScreen.tsx @@ -0,0 +1,186 @@ +import { useState } from "react"; +import { Alert, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { RootStackScreenProps } from "@/navigation/types"; +import StatusView from "@/components/common/StatusView"; +import { LeftIcon } from "@/assets/icons/CommonIcons"; +import { useUpdateMyNickname, useUserProfile } from "@/hooks/profile/useProfileApi"; +import useTokenStore from "@/stores/useTokenStore"; + +type Props = RootStackScreenProps<"UserNicknameEdit">; + +export default function UserNicknameEditScreen({ navigation }: Props) { + const { userId } = useTokenStore(); + const { data, isLoading, error, refetch } = useUserProfile(userId); + const updateNickname = useUpdateMyNickname(); + const [draftNickname, setDraftNickname] = useState(""); + + const nickname = draftNickname || data?.userNickname || ""; + const trimmedNickname = nickname.trim(); + const isValidNickname = trimmedNickname.length >= 2 && trimmedNickname.length <= 10; + const isChanged = trimmedNickname.length > 0 && trimmedNickname !== (data?.userNickname ?? ""); + const isButtonEnabled = isValidNickname && isChanged && !updateNickname.isPending; + + const handleBack = () => { + navigation.goBack(); + }; + + const handleSubmit = async () => { + if (!isButtonEnabled) return; + + try { + await updateNickname.mutateAsync(trimmedNickname); + navigation.goBack(); + } catch { + Alert.alert("닉네임 변경에 실패했습니다", "잠시 후 다시 시도해주세요."); + } + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error || !data) { + return ( + + void refetch()} + /> + + ); + } + + return ( + + + + + + 유저 닉네임 변경 + + + + + 변경할 닉네임을 입력해주세요 + + + 최대 10자 + + + + + void handleSubmit()} + disabled={!isButtonEnabled} + activeOpacity={0.85} + > + + {updateNickname.isPending ? "처리 중..." : "확인"} + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + header: { + height: 56, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 13, + borderBottomWidth: 1, + borderBottomColor: "#EFEFEF", + }, + backButton: { + width: 44, + height: 44, + justifyContent: "center", + }, + headerTitle: { + fontSize: 18, + fontWeight: "600", + color: "#171717", + }, + content: { + paddingHorizontal: 20, + paddingTop: 32, + gap: 24, + }, + title: { + fontSize: 20, + fontWeight: "600", + color: "#171717", + lineHeight: 28, + }, + inputRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + height: 60, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: "#BFBFBF", + borderRadius: 8, + }, + input: { + flex: 1, + fontSize: 16, + color: "#171717", + paddingVertical: 0, + }, + maxLength: { + fontSize: 14, + color: "#7C7C7C", + marginLeft: 8, + }, + footer: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + paddingHorizontal: 20, + paddingBottom: 34, + }, + button: { + height: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }, + buttonActive: { + backgroundColor: "#2F7D32", + }, + buttonDisabled: { + backgroundColor: "#EFEFEF", + }, + buttonText: { + fontSize: 18, + fontWeight: "600", + }, + buttonTextActive: { + color: "#FFFFFF", + }, + buttonTextDisabled: { + color: "#BFBFBF", + }, +}); diff --git a/src/pages/placeholder/PlaceholderScreen.tsx b/src/pages/placeholder/PlaceholderScreen.tsx deleted file mode 100644 index 74b2c56..0000000 --- a/src/pages/placeholder/PlaceholderScreen.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { View, Text, TouchableOpacity } from "react-native"; -import { useNavigation } from "@react-navigation/native"; - -interface PlaceholderScreenProps { - title: string; - description?: string; -} - -export default function PlaceholderScreen({ - title, - description, -}: PlaceholderScreenProps) { - const navigation = useNavigation(); - - return ( - - {title} - {description && ( - {description} - )} - navigation.goBack()} - > - 뒤로 가기 - - - ); -} diff --git a/src/pages/profile/GuestbookScreen.tsx b/src/pages/profile/GuestbookScreen.tsx new file mode 100644 index 0000000..bddeddf --- /dev/null +++ b/src/pages/profile/GuestbookScreen.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { + FlatList, + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import CommentComposer from "@/components/common/CommentComposer"; +import { useCreateGuestbook, useGuestbookList } from "@/hooks/profile/useGuestbookApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import type { GuestbookEntry } from "@/types/profile/guestbookApi.type"; + + +type Props = RootStackScreenProps<"Guestbook">; + +export default function GuestbookScreen({ navigation, route }: Props) { + const { userId, userNickname } = route.params; + const [content, setContent] = useState(""); + const { data, error, isLoading, refetch } = useGuestbookList(userId); + const createMutation = useCreateGuestbook(userId); + + const handleBack = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("Main", { screen: "Home" }); + }; + + const handleSubmit = async () => { + if (!content.trim()) { + return; + } + + try { + await createMutation.mutateAsync({ content: content.trim() }); + setContent(""); + } catch (guestbookError) { + console.error("[GuestbookScreen] Failed to create guestbook:", guestbookError); + } + }; + + const renderItem = ({ item }: { item: GuestbookEntry }) => ( + + + {item.author} + {formatDateTime(item.createdAt)} + + {item.content} + + ); + + return ( + + + + + {isLoading ? ( + + ) : error ? ( + void refetch()} + /> + ) : ( + `${item.author}-${item.createdAt}-${index}`} + renderItem={renderItem} + contentContainerStyle={[ + styles.listContent, + (data?.length ?? 0) === 0 ? styles.listContentEmpty : null, + ]} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + 아직 작성된 방명록이 없습니다. + + 가장 먼저 인사를 남겨보세요. + + + } + /> + )} + + + {/* 한글 주석: + 방명록 페이지는 댓글처럼 목록은 위에서 스크롤되고, + 입력창은 전체 화면 하단에 고정해 언제든 바로 작성할 수 있게 한다. */} + void handleSubmit()} + disabled={createMutation.isPending} + placeholder="방명록을 남겨보세요." + /> + + + + ); +} + +function formatDateTime(iso: string) { + const date = new Date(iso); + return `${date.getMonth() + 1}월 ${date.getDate()}일 ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + keyboardView: { + flex: 1, + }, + listContent: { + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 120, + gap: 12, + }, + listContentEmpty: { + flexGrow: 1, + }, + entryCard: { + borderRadius: 18, + backgroundColor: "#F8FAF6", + paddingHorizontal: 16, + paddingVertical: 14, + gap: 8, + }, + entryHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + entryAuthor: { + flex: 1, + fontSize: 14, + fontWeight: "700", + color: "#171717", + }, + entryDate: { + fontSize: 12, + color: "#6B7280", + }, + entryContent: { + fontSize: 14, + lineHeight: 20, + color: "#374151", + }, + emptyState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 20, + gap: 8, + }, + emptyTitle: { + fontSize: 16, + fontWeight: "700", + color: "#171717", + textAlign: "center", + }, + emptyDescription: { + fontSize: 14, + lineHeight: 20, + color: "#6B7280", + textAlign: "center", + }, + composerWrap: { + backgroundColor: "#FFFFFF", + }, +}); diff --git a/src/pages/profile/ProfileScreen.tsx b/src/pages/profile/ProfileScreen.tsx new file mode 100644 index 0000000..f6aa952 --- /dev/null +++ b/src/pages/profile/ProfileScreen.tsx @@ -0,0 +1,591 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import type { AxiosError } from "axios"; +import PagerView from "react-native-pager-view"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { RootStackScreenProps } from "@/navigation/types"; +import ConfirmModal from "@/components/common/ConfirmModal"; +import StatusView from "@/components/common/StatusView"; +import HomeToast from "@/components/home/HomeToast"; +import ProfileGardenScene from "@/components/profile/ProfileGardenScene"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import { useFriendWater, useUserProfile } from "@/hooks/profile/useProfileApi"; +import { useFollowUser } from "@/hooks/follow/useFollowApi"; +import { useBlockUser, useUnblockUser } from "@/hooks/block/useBlockApi"; +import useTokenStore from "@/stores/useTokenStore"; +import { createTimingLogger, debugLog } from "@/utils/debug"; +import { FollowStatus } from "@/types/profile/profileApi.type"; + +type Props = RootStackScreenProps<"Profile">; + +type BlockConfirmState = { + title: string; + description: string; + confirmLabel: string; + destructive?: boolean; + onConfirm: () => Promise | void; +} | null; + +const FRIEND_WATER_ACTION_COOLDOWN_MS = 700; + +const getFriendWaterErrorMessage = ( + status: number | null | undefined, + serverMessage: string | null | undefined +) => { + if ( + serverMessage === "팔로우한 사용자만 물 주기가 가능합니다." || + (status === 403 && serverMessage === "요청에 대한 권한이 없습니다.") + ) { + return "친구 추가 후 물을 줄 수 있어요."; + } + + if (serverMessage === "차단된 사용자입니다.") { + return "차단된 사용자에게는 물을 줄 수 없어요."; + } + + return serverMessage ?? "친구 물주기에 실패했습니다."; +}; + +const getActionErrorMessage = ( + error: unknown, + fallbackMessage: string +) => { + const axiosError = error as AxiosError<{ message?: string }>; + return axiosError.response?.data?.message ?? fallbackMessage; +}; + +const backgrounds = [ + require("@/assets/images/background/background1.webp"), + require("@/assets/images/background/background2.webp"), + require("@/assets/images/background/background3.png"), + require("@/assets/images/background/background4.webp"), +] as const; + +export default function ProfileScreen({ navigation, route }: Props) { + const { userId: myUserId } = useTokenStore(); + const userId = route.params.userId; + const { data, error, isLoading, refetch } = useUserProfile(userId); + const waterMutation = useFriendWater(userId); + const followMutation = useFollowUser(myUserId); + const blockMutation = useBlockUser(); + const unblockMutation = useUnblockUser(); + const [currentPage, setCurrentPage] = useState(0); + const [wateringGardenId, setWateringGardenId] = useState(null); + const [optimisticallyWateredGardenIds, setOptimisticallyWateredGardenIds] = useState([]); + const [isFriendWaterCooldownActive, setIsFriendWaterCooldownActive] = useState(false); + const [toastMessage, setToastMessage] = useState(null); + const [isBlockedUser, setIsBlockedUser] = useState(false); + const [blockConfirmState, setBlockConfirmState] = useState(null); + const initialLoadTimingRef = useRef | null>(null); + + const isMe = String(userId) === myUserId; + const isBlockActionPending = blockMutation.isPending || unblockMutation.isPending; + + useEffect(() => { + initialLoadTimingRef.current = createTimingLogger("ProfileScreen", "initial profile load", { userId }); + }, [userId]); + + useEffect(() => { + if (!data || !initialLoadTimingRef.current) { + return; + } + + initialLoadTimingRef.current({ gardenCount: data.userGardens.length }); + initialLoadTimingRef.current = null; + }, [data]); + + useEffect(() => { + if (!data) { + return; + } + + setOptimisticallyWateredGardenIds(previous => + previous.filter(gardenId => { + const garden = data.userGardens.find(item => item.gardenId === gardenId); + return garden?.isWateringAbleByMe ?? false; + }) + ); + }, [data]); + + const handleBack = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate("Main", { screen: "Feed" }); + }; + + const handleOpenGuestbook = () => { + navigation.navigate("Guestbook", { + userId, + userNickname: data?.userNickname, + }); + }; + + const handleFriendWater = async (gardenId: number) => { + if (waterMutation.isPending || isFriendWaterCooldownActive || isBlockedUser) { + return; + } + + if (!data || data.followStatus !== FollowStatus.FOLLOWING) { + setToastMessage("친구 추가 후 물을 줄 수 있어요."); + return; + } + + const targetGarden = data.userGardens.find(garden => garden.gardenId === gardenId); + if (!targetGarden?.isWateringAbleByMe) { + setToastMessage( + data.leftWaterCountForOthers <= 0 + ? "오늘 줄 수 있는 친구 물주기를 모두 사용했어요." + : "오늘은 이미 물을 주었습니다." + ); + return; + } + + const finishActionTiming = createTimingLogger("ProfileScreen", "friend water action", { + userId, + gardenId, + }); + + setOptimisticallyWateredGardenIds(previous => + previous.includes(gardenId) ? previous : [...previous, gardenId] + ); + setIsFriendWaterCooldownActive(true); + setWateringGardenId(gardenId); + setToastMessage("친구 정원에 물을 주었습니다."); + + setTimeout(() => { + setWateringGardenId(prev => (prev === gardenId ? null : prev)); + }, 1000); + + setTimeout(() => { + setIsFriendWaterCooldownActive(false); + }, FRIEND_WATER_ACTION_COOLDOWN_MS); + + try { + await waterMutation.mutateAsync(gardenId); + finishActionTiming({ startedImmediately: true }); + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }>; + const status = axiosError.response?.status; + const serverMessage = axiosError.response?.data?.message; + + setOptimisticallyWateredGardenIds(previous => previous.filter(id => id !== gardenId)); + setIsFriendWaterCooldownActive(false); + setWateringGardenId(prev => (prev === gardenId ? null : prev)); + finishActionTiming({ + startedImmediately: true, + rolledBack: true, + status: status ?? null, + }); + debugLog("ProfileScreen", "friend water action failed", { + userId, + gardenId, + status: status ?? null, + serverMessage: serverMessage ?? null, + }); + setToastMessage(getFriendWaterErrorMessage(status, serverMessage)); + } + }; + + const handleToggleBlock = () => { + if (isMe || isBlockActionPending) { + return; + } + + if (isBlockedUser) { + setBlockConfirmState({ + title: "차단 해제", + description: "이 사용자의 차단을 해제할까요?", + confirmLabel: "차단 해제", + onConfirm: async () => { + try { + await unblockMutation.mutateAsync(userId); + setIsBlockedUser(false); + setToastMessage("사용자 차단을 해제했습니다."); + } catch (error) { + Alert.alert("차단 해제 실패", getActionErrorMessage(error, "잠시 후 다시 시도해주세요.")); + } + }, + }); + return; + } + + setBlockConfirmState({ + title: "차단", + description: "이 사용자를 차단할까요?\n서로 팔로우가 해제되고 콘텐츠가 숨겨집니다.", + confirmLabel: "차단", + destructive: true, + onConfirm: async () => { + try { + await blockMutation.mutateAsync(userId); + setIsBlockedUser(true); + setToastMessage("사용자를 차단했습니다."); + } catch (error) { + Alert.alert("차단 실패", getActionErrorMessage(error, "잠시 후 다시 시도해주세요.")); + } + }, + }); + }; + + const handleConfirmBlock = async () => { + if (!blockConfirmState || isBlockActionPending) { + return; + } + + const currentConfirm = blockConfirmState; + setBlockConfirmState(null); + await currentConfirm.onConfirm(); + }; + + const followAction = useMemo(() => { + if (!data || isMe || isBlockedUser) { + return null; + } + + switch (data.followStatus) { + case FollowStatus.NOT_FOLLOWING: + return { + kind: "button" as const, + label: "친구 추가", + onPress: () => void followMutation.mutateAsync(userId), + pending: followMutation.isPending, + }; + case FollowStatus.FOLLOW_BACK_POSSIBLE: + return { + kind: "button" as const, + label: "맞팔로우", + onPress: () => void followMutation.mutateAsync(userId), + pending: followMutation.isPending, + }; + case FollowStatus.FOLLOWING: + return { + kind: "badge" as const, + label: "이미 친구", + }; + default: + return null; + } + }, [data, followMutation, userId, isMe, isBlockedUser]); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + void refetch()} + /> + + ); + } + + if (!data) { + return ( + + + + ); + } + + const scenes = data.userGardens.map((garden, index) => ({ + key: `profile-garden-${garden.gardenId}`, + background: backgrounds[index % backgrounds.length], + garden, + })); + + const renderProfileHeader = () => ( + + + + + + {data.profileImageUrl ? ( + + ) : null} + + {data.userNickname} + + + {followAction ? ( + followAction.kind === "button" ? ( + + + {followAction.pending ? "처리 중..." : followAction.label} + + + ) : ( + {followAction.label} + ) + ) : null} + {!isMe ? ( + + + {isBlockActionPending ? "처리 중..." : isBlockedUser ? "차단 해제" : "차단"} + + + ) : null} + + + + ); + + if (scenes.length === 0) { + return ( + + {renderProfileHeader()} + + + + + {data.profileImageUrl ? ( + + ) : null} + + {data.userNickname} + + + + 정원 정보가 없습니다. + + 현재 API 기준으로 표시할 정원 데이터가 없어 기본 정보만 표시합니다. + + + + + + setBlockConfirmState(null)} + onConfirm={() => void handleConfirmBlock()} + /> + + ); + } + + return ( + + setCurrentPage(event.nativeEvent.position)} + > + {scenes.map(scene => ( + + void handleFriendWater(scene.garden.gardenId)} + onPressGuestbook={handleOpenGuestbook} + waterDisabled={ + isBlockedUser || + waterMutation.isPending || + isFriendWaterCooldownActive || + optimisticallyWateredGardenIds.includes(scene.garden.gardenId) + } + /> + + ))} + + + {renderProfileHeader()} + + + + {scenes.map((scene, index) => ( + + ))} + + + + {toastMessage ? setToastMessage(null)} /> : null} + + setBlockConfirmState(null)} + onConfirm={() => void handleConfirmBlock()} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#F4F7F0", + }, + sceneContainer: { + flex: 1, + backgroundColor: "#DDE8D6", + }, + pager: { + flex: 1, + }, + page: { + flex: 1, + }, + headerSafeArea: { + position: "absolute", + top: 0, + left: 0, + right: 0, + backgroundColor: "#FFFFFF", + }, + userInfoBar: { + backgroundColor: "#FFFFFF", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: "#EFEFEF", + }, + userInfoLeft: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + userAvatarWrap: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "#E5E7EB", + overflow: "hidden", + }, + userAvatarImage: { + width: "100%", + height: "100%", + }, + userNickname: { + fontSize: 18, + fontWeight: "600", + color: "#171717", + }, + userInfoRight: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + followText: { + fontSize: 14, + fontWeight: "400", + color: "#3AB40B", + }, + followedText: { + fontSize: 14, + color: "#BFBFBF", + }, + blockText: { + fontSize: 12, + color: "#9CA3AF", + }, + blockActiveText: { + color: "#6B7280", + }, + overlaySafeArea: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + }, + pagination: { + paddingBottom: 96, + alignSelf: "center", + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + dot: { + width: 8, + height: 8, + borderRadius: 999, + }, + dotActive: { + backgroundColor: "#FFFFFF", + }, + dotInactive: { + backgroundColor: "rgba(255,255,255,0.45)", + }, + emptyContainer: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + emptyScrollSafeArea: { + flex: 1, + }, + emptyScrollContent: { + paddingTop: 104, + paddingBottom: 28, + }, + summaryRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 14, + gap: 10, + }, + profileImageWrap: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "#E5E7EB", + overflow: "hidden", + }, + profileImage: { + width: "100%", + height: "100%", + }, + nickname: { + flex: 1, + fontSize: 16, + fontWeight: "600", + color: "#171717", + }, + emptyGardenWrap: { + paddingHorizontal: 20, + paddingVertical: 28, + gap: 6, + }, + emptyGardenTitle: { + fontSize: 16, + fontWeight: "700", + color: "#171717", + }, + emptyGardenDescription: { + fontSize: 14, + lineHeight: 20, + color: "#6B7280", + }, +}); diff --git a/src/pages/register/RegisterScreen.tsx b/src/pages/register/RegisterScreen.tsx index 24cd02a..0dcd918 100644 --- a/src/pages/register/RegisterScreen.tsx +++ b/src/pages/register/RegisterScreen.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useEffect, useState, useRef } from "react"; import { View, Text, @@ -13,6 +13,8 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { RootStackParamList } from "@/navigation/types"; import { LeftIcon, EditIcon } from "@/assets/icons/CommonIcons"; import { useRegister } from "@/hooks/register/useRegister"; +import useRegistrationStore from "@/stores/useRegistrationStore"; +import { debugLog, debugScreenMounted } from "@/utils/debug"; type NavigationProp = NativeStackNavigationProp; @@ -21,9 +23,14 @@ export default function RegisterScreen() { const [nickname, setNickname] = useState(""); const navigation = useNavigation(); const { register, isLoading } = useRegister(); + const resetRegistration = useRegistrationStore(state => state.reset); const isValidNickname = nickname.length >= 2 && nickname.length <= 10; + useEffect(() => { + debugScreenMounted("RegisterScreen"); + }, []); + const handleWrapperPress = () => { inputRef.current?.focus(); }; @@ -31,17 +38,27 @@ export default function RegisterScreen() { const handleRegister = async () => { if (!isValidNickname) return; + debugLog("RegisterScreen", "Guest register submit", { + nicknameLength: nickname.length, + }); + try { await register(nickname); + resetRegistration(); + debugLog("RegisterScreen", "Navigate -> RegistrationAvatar after register success"); navigation.navigate("RegistrationAvatar"); } catch (error) { console.error(error); - // 에러가 발생해도 다음 화면으로 이동 (기존 웹과 동일한 동작) + debugLog("RegisterScreen", "Register failed, continuing to RegistrationAvatar", { + error, + }); + resetRegistration(); navigation.navigate("RegistrationAvatar"); } }; const handleBackPress = () => { + debugLog("RegisterScreen", "Navigate -> Onboarding"); navigation.navigate("Onboarding"); }; @@ -50,7 +67,6 @@ export default function RegisterScreen() { style={styles.container} behavior={Platform.OS === "ios" ? "padding" : "height"} > - {/* Header */} @@ -59,7 +75,6 @@ export default function RegisterScreen() { - {/* Content */} @@ -82,18 +97,12 @@ export default function RegisterScreen() { placeholderTextColor="#9CA3AF" value={nickname} onChangeText={setNickname} - minLength={2} maxLength={10} /> - {nickname.length > 0 ? ( - - ) : ( - 2~10자 - )} + {nickname.length > 0 ? : 2~10자} - {/* Button */} ; + +export default function SocialNicknameScreen({ navigation, route }: Props) { + const initialNickname = route.params?.initialNickname?.trim() ?? ""; + const [nickname, setNickname] = useState( + initialNickname.startsWith("user") ? "" : initialNickname + ); + const updateNickname = useUpdateMyNickname(); + + const trimmedNickname = nickname.trim(); + const isValidNickname = trimmedNickname.length >= 2 && trimmedNickname.length <= 10; + + const helperText = useMemo(() => { + if (trimmedNickname.length === 0) { + return "방명록과 피드에 표시될 이름을 입력해주세요."; + } + if (trimmedNickname.length < 2) { + return "닉네임은 2자 이상이어야 합니다."; + } + if (trimmedNickname.length > 10) { + return "닉네임은 10자 이하로 입력해주세요."; + } + return "이 닉네임으로 프로필과 방명록 작성자명이 표시됩니다."; + }, [trimmedNickname]); + + const handleBack = async () => { + await logout(); + navigation.reset({ + index: 0, + routes: [{ name: "Onboarding" }], + }); + }; + + const handleSubmit = async () => { + if (!isValidNickname || updateNickname.isPending) { + return; + } + + try { + await updateNickname.mutateAsync(trimmedNickname); + navigation.reset({ + index: 0, + routes: [{ name: "RegistrationAvatar" }], + }); + } catch { + Alert.alert( + "닉네임 저장에 실패했습니다", + "네트워크 상태를 확인한 뒤 다시 시도해주세요." + ); + } + }; + + return ( + + + void handleBack()} activeOpacity={0.7} style={styles.sideButton}> + 뒤로 + + 닉네임 설정 + + + + + + 소셜 로그인 완료 + 먼저 사용할 닉네임을 정해주세요. + + {/* 한글 주석: + 소셜 신규 유저는 기존 비회원 가입처럼 닉네임 입력 화면을 거치지 않았기 때문에 + 식물 등록으로 넘어가기 전에 유저 닉네임을 먼저 확정한다. */} + 방명록 작성자명과 프로필 이름으로 바로 노출되기 때문에, 지금 한 번 먼저 정하고 시작합니다. + + + + + + + void handleSubmit()} + primaryDisabled={!isValidNickname || updateNickname.isPending} + primaryLoading={updateNickname.isPending} + /> + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#F7F8F4", + }, + header: { + height: 56, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + backgroundColor: "#FFFFFF", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#E5E7EB", + }, + sideButton: { + width: 56, + height: 44, + justifyContent: "center", + }, + backText: { + fontSize: 14, + fontWeight: "600", + color: "#374151", + }, + headerTitle: { + fontSize: 17, + fontWeight: "700", + color: "#171717", + }, + content: { + padding: 20, + gap: 18, + }, + heroCard: { + borderRadius: 22, + padding: 20, + backgroundColor: "#234A2F", + gap: 8, + }, + eyebrow: { + fontSize: 12, + color: "#D7E9D8", + }, + heroTitle: { + fontSize: 24, + lineHeight: 32, + fontWeight: "700", + color: "#FFFFFF", + }, + heroDescription: { + fontSize: 14, + lineHeight: 20, + color: "#E5F4E5", + }, +}); diff --git a/src/pages/registration/RegistrationAvatarScreen.tsx b/src/pages/registration/RegistrationAvatarScreen.tsx new file mode 100644 index 0000000..79ed4c9 --- /dev/null +++ b/src/pages/registration/RegistrationAvatarScreen.tsx @@ -0,0 +1,169 @@ +import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import type { RootStackScreenProps } from "@/navigation/types"; +import useRegistrationStore from "@/stores/useRegistrationStore"; + +type Props = RootStackScreenProps<"RegistrationAvatar">; + +type EntryMode = "initial" | "garden"; + +const selectionImage = require("@/assets/images/creationAvatar/SelectionDefultImg.png"); +const creationImage = require("@/assets/images/creationAvatar/CreationDefultImg.png"); + +export default function RegistrationAvatarScreen({ navigation, route }: Props) { + const { setMode } = useRegistrationStore(); + const entry: EntryMode = route.params?.entry ?? "initial"; + const isGardenEntry = entry === "garden"; + + const goSelection = () => { + setMode("selection"); + navigation.navigate("RegistrationSelectionDetail", { entry }); + }; + + const goCreation = () => { + setMode("creation"); + navigation.navigate("RegistrationCreationDetail", { entry }); + }; + + return ( + + navigation.navigate("Main", { screen: "Home" })} + /> + + + + + 아바타 선택 + 00종의 아바타 중에서{"\n"}선택할 수 있어요 + + 선택하러 가기 + + + + + + + + + + 나만의 아바타 + 내 식물의 생김새를{"\n"}반영한 나만의 아바타를{"\n"}만들 수 있어요 + + 만들러 가기 + + + + + + + + + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }) + } + > + + {isGardenEntry ? "다음" : "나중에 만들기"} + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + paddingTop: 18, + paddingBottom: 24, + }, + card: { + minHeight: 300, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 20, + gap: 12, + }, + divider: { + height: 1, + backgroundColor: "#EFEFEF", + }, + copyWrap: { + flex: 1, + gap: 12, + }, + cardTitle: { + fontSize: 40 / 2, + lineHeight: 56 / 2, + fontWeight: "700", + color: "#171717", + }, + cardDescription: { + fontSize: 16, + lineHeight: 40 / 2, + color: "#171717", + }, + cardActionButton: { + marginTop: 8, + alignSelf: "flex-start", + minWidth: 146, + minHeight: 44, + borderRadius: 8, + backgroundColor: "#6FCF4A", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 18, + }, + cardActionLabel: { + fontSize: 32 / 2, + lineHeight: 54 / 2, + fontWeight: "600", + color: "#FFFFFF", + }, + cardImage: { + width: 184, + height: 184, + opacity: 0.95, + }, + footer: { + paddingHorizontal: 20, + paddingTop: 10, + paddingBottom: 20, + }, + footerButton: { + minHeight: 56, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }, + footerButtonSecondary: { + backgroundColor: "#EFF9EA", + }, + footerButtonDisabled: { + backgroundColor: "#EAEAEA", + }, + footerLabel: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + }, + footerLabelSecondary: { + color: "#46C02B", + }, + footerLabelDisabled: { + color: "#BFBFBF", + }, +}); diff --git a/src/pages/registration/RegistrationCreationCompleteScreen.tsx b/src/pages/registration/RegistrationCreationCompleteScreen.tsx new file mode 100644 index 0000000..4062f8b --- /dev/null +++ b/src/pages/registration/RegistrationCreationCompleteScreen.tsx @@ -0,0 +1,92 @@ +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import type { RootStackScreenProps } from "@/navigation/types"; +import { useHomeSummaryStore } from "@/stores/useHomeSummaryStore"; + +type Props = RootStackScreenProps<"RegistrationCreationComplete">; + +export default function RegistrationCreationCompleteScreen({ navigation, route }: Props) { + const username = useHomeSummaryStore(state => state.user?.username) ?? ""; + const { imageUrl } = route.params; + + return ( + + + + + {username || "OO"}님만의{"\n"}아바타가 완성되었어요! + + + + + + + + navigation.replace("RegistrationPlantNickname")} + > + 다음 + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + flex: 1, + paddingHorizontal: 25, + paddingTop: 32, + }, + title: { + fontSize: 20, + lineHeight: 28, + fontWeight: "600", + color: "#171717", + }, + previewCard: { + width: 258, + height: 292, + marginTop: 66, + marginLeft: 43, + borderRadius: 12, + borderWidth: 2, + borderColor: "#72D14E", + backgroundColor: "#EEF9EA", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 19, + paddingVertical: 16, + }, + previewImage: { + width: 220, + height: 261, + borderRadius: 8, + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 34, + }, + nextButton: { + minHeight: 56, + borderRadius: 8, + backgroundColor: "#72D14E", + alignItems: "center", + justifyContent: "center", + }, + nextLabel: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, +}); + diff --git a/src/pages/registration/RegistrationCreationDetailScreen.tsx b/src/pages/registration/RegistrationCreationDetailScreen.tsx new file mode 100644 index 0000000..28267e5 --- /dev/null +++ b/src/pages/registration/RegistrationCreationDetailScreen.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import * as ImagePicker from "expo-image-picker"; +import { + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import AvatarPreviewCard from "@/components/registration/AvatarPreviewCard"; +import RegistrationFooter from "@/components/registration/RegistrationFooter"; +import type { RootStackScreenProps } from "@/navigation/types"; +import useRegistrationStore from "@/stores/useRegistrationStore"; + +type Props = RootStackScreenProps<"RegistrationCreationDetail">; + +export default function RegistrationCreationDetailScreen({ navigation, route }: Props) { + const entry = route.params?.entry; + const [permissionRequested, setPermissionRequested] = useState(false); + const { + creationDetail, + updateCreationDetail, + setSelectedPreview, + } = useRegistrationStore(); + + const previewImageUrl = creationDetail.imageUri || null; + + const handlePickImage = async () => { + if (!permissionRequested) { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + setPermissionRequested(true); + + if (!permission.granted) { + Alert.alert("권한 필요", "생성형 아바타 이미지를 고르려면 사진 접근 권한이 필요합니다."); + return; + } + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + quality: 0.9, + }); + + if (result.canceled || !result.assets[0]) { + return; + } + + const asset = result.assets[0]; + const fileName = asset.fileName ?? `avatar-${Date.now()}.jpg`; + const fileType = asset.mimeType ?? "image/jpeg"; + + updateCreationDetail({ + imageUri: asset.uri, + uploadedImageUrl: "", + }); + setSelectedPreview({ + masterId: null, + imageUrl: asset.uri, + description: "선택한 이미지를 업로드합니다.", + }); + + navigation.navigate("RegistrationCreationPending", { + entry, + imageUri: asset.uri, + fileName, + fileType, + }); + }; + + return ( + + navigation.navigate("RegistrationAvatar", { entry })} + /> + + + 사진을 선택해주세요 + 사진 업로드 후 아바타 생성이 시작됩니다. + + + + + void handlePickImage()}> + + {creationDetail.imageUri ? "이미지 다시 선택" : "이미지 선택"} + + + + + + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }) + } + /> + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#F7F8F4", + }, + content: { + padding: 20, + gap: 18, + }, + headerBlock: { + gap: 8, + }, + title: { + fontSize: 24, + lineHeight: 32, + fontWeight: "700", + color: "#171717", + }, + subtitle: { + fontSize: 14, + lineHeight: 20, + color: "#6B7280", + }, + actionButton: { + minHeight: 52, + alignItems: "center", + justifyContent: "center", + borderRadius: 16, + backgroundColor: "#2F7D32", + }, + actionButtonText: { + fontSize: 15, + fontWeight: "700", + color: "#FFFFFF", + }, +}); diff --git a/src/pages/registration/RegistrationCreationPendingScreen.tsx b/src/pages/registration/RegistrationCreationPendingScreen.tsx new file mode 100644 index 0000000..7d5cb90 --- /dev/null +++ b/src/pages/registration/RegistrationCreationPendingScreen.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef } from "react"; +import { Alert, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import PendingCharacter from "@/assets/images/creationAvatar/PendingImage.svg"; +import { useUploadCreationAvatar } from "@/hooks/avatars/useAvatarApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import useRegistrationStore from "@/stores/useRegistrationStore"; + +type Props = RootStackScreenProps<"RegistrationCreationPending">; + +export default function RegistrationCreationPendingScreen({ navigation, route }: Props) { + const uploadCreationAvatar = useUploadCreationAvatar(); + const hasStartedRef = useRef(false); + const { entry, imageUri, fileName, fileType } = route.params; + const { + updateCreationDetail, + setMode, + setSelectedMaster, + setSelectedPreview, + } = useRegistrationStore(); + + useEffect(() => { + if (hasStartedRef.current) { + return; + } + hasStartedRef.current = true; + + const formData = new FormData(); + formData.append("image", { + uri: imageUri, + name: fileName, + type: fileType, + } as never); + + void uploadCreationAvatar + .mutateAsync(formData) + .then(response => { + updateCreationDetail({ + imageUri, + uploadedImageUrl: response.imageUrl, + }); + setMode("creation"); + setSelectedMaster(null); + setSelectedPreview({ + masterId: null, + imageUrl: response.imageUrl, + description: "업로드가 완료되었습니다. 이 이미지를 기반으로 식물을 등록합니다.", + }); + + navigation.replace("RegistrationCreationComplete", { + entry, + imageUrl: response.imageUrl, + }); + }) + .catch(error => { + const message = error instanceof Error ? error.message : "이미지 업로드에 실패했습니다."; + Alert.alert("업로드 실패", message, [ + { + text: "다시 선택", + onPress: () => navigation.replace("RegistrationCreationDetail", { entry }), + }, + ]); + }); + }, [ + entry, + fileName, + fileType, + imageUri, + navigation, + setMode, + setSelectedMaster, + setSelectedPreview, + updateCreationDetail, + uploadCreationAvatar, + ]); + + return ( + + + + + + + 아바타를 만들고 있어요 + 잠시만 기다려주세요... + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + flex: 1, + alignItems: "center", + justifyContent: "center", + marginTop: -38, + }, + group: { + width: 165, + minHeight: 174, + alignItems: "center", + gap: 16, + }, + messageWrap: { + width: 165, + alignItems: "center", + }, + message: { + fontSize: 18, + lineHeight: 29, + fontWeight: "600", + color: "#7C7C7C", + textAlign: "center", + }, +}); diff --git a/src/pages/registration/RegistrationPlantNicknameScreen.tsx b/src/pages/registration/RegistrationPlantNicknameScreen.tsx new file mode 100644 index 0000000..f69a66f --- /dev/null +++ b/src/pages/registration/RegistrationPlantNicknameScreen.tsx @@ -0,0 +1,232 @@ +import { Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import ScreenHeader from "@/components/common/ScreenHeader"; +import { useFinalChoiceAvatar } from "@/hooks/avatars/useAvatarApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import useRegistrationStore from "@/stores/useRegistrationStore"; + +const MAX_NICKNAME_LENGTH = 6; + +type Props = RootStackScreenProps<"RegistrationPlantNickname">; + +export default function RegistrationPlantNicknameScreen({ navigation }: Props) { + const { + mode, + nickname, + selectedMaster, + selectedPreview, + creationDetail, + setNickname, + reset, + } = useRegistrationStore(); + const finalChoiceAvatar = useFinalChoiceAvatar(); + + const trimmedNickname = nickname.trim(); + const isTooLong = trimmedNickname.length > MAX_NICKNAME_LENGTH; + const isEmpty = trimmedNickname.length === 0; + const isInvalid = isEmpty || isTooLong; + const canSubmitToApi = + (mode === "selection" && Boolean(selectedMaster?.id && selectedMaster.defaultImageUrl)) || + (mode === "creation" && Boolean(creationDetail.uploadedImageUrl)); + + const previewImageUrl = + selectedPreview?.imageUrl ?? creationDetail.uploadedImageUrl ?? selectedMaster?.defaultImageUrl ?? null; + + const completeFlow = async () => { + if (isInvalid || !canSubmitToApi || finalChoiceAvatar.isPending) { + return; + } + + const payload = + mode === "selection" && selectedMaster + ? { + nickname: trimmedNickname, + imageUrl: selectedMaster.defaultImageUrl, + masterId: selectedMaster.id, + } + : { + nickname: trimmedNickname, + imageUrl: creationDetail.uploadedImageUrl, + masterId: null, + }; + + try { + await finalChoiceAvatar.mutateAsync(payload); + reset(); + navigation.reset({ + index: 0, + routes: [{ name: "Main", params: { screen: "Home" } }], + }); + } catch { + // Keep same visual state and allow retry. + } + }; + + const goBackTarget = () => { + if (mode === "selection") { + navigation.navigate("RegistrationSelectionDetail"); + return; + } + + navigation.navigate("RegistrationCreationDetail"); + }; + + return ( + + + + + 식물의 별명을 지어주세요 + + + {previewImageUrl ? ( + + ) : null} + + + + + {isEmpty ? 최대 6자 : null} + + + {isTooLong ? 최대 6자 입력해주세요. : null} + + + + void completeFlow()} + style={[ + styles.primaryButton, + isInvalid || !canSubmitToApi || finalChoiceAvatar.isPending ? styles.primaryButtonDisabled : null, + ]} + > + + 내 텃밭으로 가기 + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 32, + alignItems: "center", + }, + title: { + width: "100%", + paddingHorizontal: 5, + fontSize: 20, + lineHeight: 28, + fontWeight: "600", + color: "#171717", + marginBottom: 73, + }, + previewCard: { + width: 258, + height: 292, + borderRadius: 12, + borderWidth: 2, + borderColor: "#72D14E", + backgroundColor: "#EEF9EA", + marginBottom: 16, + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + }, + previewImage: { + width: 234, + height: 268, + }, + inputWrap: { + width: 353, + height: 60, + borderWidth: 1, + borderColor: "#BFBFBF", + borderRadius: 8, + paddingHorizontal: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + inputWrapActive: { + borderColor: "#171717", + }, + inputWrapError: { + borderColor: "#F76868", + }, + input: { + flex: 1, + fontSize: 16, + lineHeight: 26, + color: "#171717", + paddingVertical: 0, + }, + maxCount: { + fontSize: 14, + lineHeight: 22, + color: "#7C7C7C", + marginLeft: 8, + }, + errorText: { + width: "100%", + paddingLeft: 17, + marginTop: 8, + fontSize: 14, + lineHeight: 22, + color: "#7C7C7C", + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 20, + }, + primaryButton: { + minHeight: 56, + borderRadius: 8, + backgroundColor: "#72D14E", + alignItems: "center", + justifyContent: "center", + }, + primaryButtonDisabled: { + backgroundColor: "#EFEFEF", + }, + primaryButtonText: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, + primaryButtonTextDisabled: { + color: "#BFBFBF", + }, +}); + diff --git a/src/pages/registration/RegistrationSelectionDetailScreen.tsx b/src/pages/registration/RegistrationSelectionDetailScreen.tsx new file mode 100644 index 0000000..ba150a8 --- /dev/null +++ b/src/pages/registration/RegistrationSelectionDetailScreen.tsx @@ -0,0 +1,481 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Animated, + Dimensions, + Image, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import ScreenHeader from "@/components/common/ScreenHeader"; +import StatusView from "@/components/common/StatusView"; +import { useAvatarMasters } from "@/hooks/avatars/useAvatarApi"; +import type { RootStackScreenProps } from "@/navigation/types"; +import useRegistrationStore from "@/stores/useRegistrationStore"; + +type Props = RootStackScreenProps<"RegistrationSelectionDetail">; + +type PaginationDotTone = "active" | "default" | "edge" | "hidden"; + +const { width: screenWidth } = Dimensions.get("window"); +const CARD_WIDTH = 258; +const CARD_HEIGHT = 292; +const CARD_SPACING = 16; +const SNAP_INTERVAL = CARD_WIDTH + CARD_SPACING; +const SIDE_PADDING = Math.max((screenWidth - CARD_WIDTH) / 2, 20); +const DOT_SIZE = 8; +const DOT_SPACING = 6; + +function buildPaginationSlots(total: number, currentIndex: number): PaginationDotTone[] { + if (total <= 0) { + return ["hidden", "hidden", "hidden"]; + } + + if (total === 1) { + return ["hidden", "active", "hidden"]; + } + + if (total === 2) { + return currentIndex === 0 + ? ["hidden", "active", "default"] + : ["default", "active", "hidden"]; + } + + if (currentIndex === 0) { + return ["hidden", "active", "default"]; + } + + if (currentIndex === total - 1) { + return ["default", "active", "hidden"]; + } + + if (currentIndex === 1) { + return ["default", "active", "edge"]; + } + + if (currentIndex === total - 2) { + return ["edge", "active", "default"]; + } + + return ["edge", "active", "edge"]; +} + +function getDotColor(tone: PaginationDotTone) { + return tone === "active" ? "#7C7C7C" : "#D9D9D9"; +} + +function getDotOpacity(tone: PaginationDotTone) { + if (tone === "active" || tone === "default") { + return 1; + } + + if (tone === "edge") { + return 0.35; + } + + return 0; +} + +function getDotScale(tone: PaginationDotTone) { + if (tone === "active") { + return 1; + } + + if (tone === "default") { + return 0.92; + } + + if (tone === "edge") { + return 0.82; + } + + return 0.4; +} + +function getClampedIndex(total: number, offsetX: number) { + if (total <= 0) { + return 0; + } + + return Math.min(total - 1, Math.max(0, Math.round(offsetX / SNAP_INTERVAL))); +} + +export default function RegistrationSelectionDetailScreen({ + navigation, + route, +}: Props) { + const entry = route.params?.entry; + const { data, isLoading, isError, refetch } = useAvatarMasters(); + const scrollRef = useRef(null); + const dotOpacity = useRef( + Array.from({ length: 3 }, () => new Animated.Value(0)), + ).current; + const dotScale = useRef( + Array.from({ length: 3 }, () => new Animated.Value(0.4)), + ).current; + const { selectedMaster, setMode, setSelectedMaster, setSelectedPreview } = + useRegistrationStore(); + const [indicatorIndex, setIndicatorIndex] = useState(0); + + useEffect(() => { + if (!selectedMaster && data && data.length > 0) { + setSelectedMaster(data[0]); + setSelectedPreview({ + masterId: data[0].id, + description: data[0].description, + imageUrl: data[0].defaultImageUrl, + }); + setIndicatorIndex(0); + } + }, [data, selectedMaster, setSelectedMaster, setSelectedPreview]); + + const selectedIndex = + data && selectedMaster + ? Math.max( + data.findIndex(avatar => avatar.id === selectedMaster.id), + 0, + ) + : 0; + + useEffect(() => { + setIndicatorIndex(selectedIndex); + }, [selectedIndex]); + + const paginationSlots = useMemo( + () => buildPaginationSlots(data?.length ?? 0, indicatorIndex), + [data?.length, indicatorIndex], + ); + + useEffect(() => { + Animated.parallel( + paginationSlots.map((tone, index) => + Animated.parallel([ + Animated.timing(dotOpacity[index], { + toValue: getDotOpacity(tone), + duration: 140, + useNativeDriver: false, + }), + Animated.timing(dotScale[index], { + toValue: getDotScale(tone), + duration: 140, + useNativeDriver: false, + }), + ]), + ), + ).start(); + }, [dotOpacity, dotScale, paginationSlots]); + + const selectAvatarAtIndex = (index: number, shouldScroll = false) => { + if (!data || index < 0 || index >= data.length) { + return; + } + + const avatar = data[index]; + setSelectedMaster(avatar); + setSelectedPreview({ + masterId: avatar.id, + description: avatar.description, + imageUrl: avatar.defaultImageUrl, + }); + setIndicatorIndex(index); + + if (shouldScroll) { + scrollRef.current?.scrollTo({ + x: index * SNAP_INTERVAL, + animated: true, + }); + } + }; + + const handleScroll = (event: NativeSyntheticEvent) => { + const nextIndex = getClampedIndex(data?.length ?? 0, event.nativeEvent.contentOffset.x); + + if (nextIndex !== indicatorIndex) { + setIndicatorIndex(nextIndex); + } + }; + + const handleMomentumScrollEnd = ( + event: NativeSyntheticEvent, + ) => { + if (!data || data.length === 0) { + return; + } + + const nextIndex = getClampedIndex(data.length, event.nativeEvent.contentOffset.x); + + if (data[nextIndex].id !== selectedMaster?.id) { + selectAvatarAtIndex(nextIndex); + } + }; + + const goNext = () => { + if (!selectedMaster) { + return; + } + + setMode("selection"); + setSelectedPreview({ + masterId: selectedMaster.id, + description: selectedMaster.description, + imageUrl: selectedMaster.defaultImageUrl, + }); + navigation.navigate("RegistrationPlantNickname"); + }; + + if (isLoading) { + return ( + + navigation.navigate("RegistrationAvatar", { entry })} + /> + + + ); + } + + if (isError) { + return ( + + navigation.navigate("RegistrationAvatar", { entry })} + /> + void refetch()} + /> + + ); + } + + if (!data || data.length === 0) { + return ( + + navigation.navigate("RegistrationAvatar", { entry })} + /> + + + ); + } + + return ( + + navigation.navigate("RegistrationAvatar", { entry })} + /> + + + 원하는 아바타를 선택해주세요 + + + + {data.map((avatar, index) => { + const isSelected = selectedMaster?.id === avatar.id; + + return ( + selectAvatarAtIndex(index, true)} + > + + + + {avatar.description} + + ); + })} + + + + {paginationSlots.map((tone, index) => ( + + + + ))} + + + + + + + + 다음 + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + content: { + flex: 1, + }, + title: { + marginTop: 32, + marginLeft: 25, + fontSize: 20, + lineHeight: 28, + fontWeight: "600", + color: "#171717", + }, + carouselSection: { + flex: 1, + paddingTop: 84, + position: "relative", + }, + carouselContent: { + paddingHorizontal: SIDE_PADDING, + }, + cardBlock: { + width: CARD_WIDTH, + marginRight: CARD_SPACING, + alignItems: "center", + }, + cardFrame: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + borderRadius: 12, + borderWidth: 2, + borderColor: "#72D14E", + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + }, + cardFrameSelected: { + backgroundColor: "#EEF9EA", + opacity: 1, + }, + cardFrameIdle: { + backgroundColor: "#FFFFFF", + opacity: 0.5, + }, + cardImage: { + width: CARD_WIDTH - 24, + height: CARD_HEIGHT - 24, + }, + cardImageSelected: { + opacity: 1, + }, + cardImageIdle: { + opacity: 0.92, + }, + cardLabel: { + marginTop: 16, + width: "100%", + paddingHorizontal: 8, + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#171717", + textAlign: "center", + }, + pagination: { + position: "absolute", + left: 0, + right: 0, + bottom: 180, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + }, + paginationSlot: { + width: DOT_SIZE + DOT_SPACING * 2, + alignItems: "center", + justifyContent: "center", + }, + paginationDot: { + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: 999, + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 18, + }, + primaryButton: { + height: 56, + borderRadius: 8, + backgroundColor: "#72D14E", + alignItems: "center", + justifyContent: "center", + }, + primaryButtonDisabled: { + backgroundColor: "#7C7C7C", + }, + primaryButtonLabel: { + fontSize: 18, + lineHeight: 27, + fontWeight: "600", + color: "#FFFFFF", + }, + primaryButtonLabelDisabled: { + color: "#BFBFBF", + }, +}); + diff --git a/src/stores/useEmotionSurveyStore.ts b/src/stores/useEmotionSurveyStore.ts new file mode 100644 index 0000000..7825de8 --- /dev/null +++ b/src/stores/useEmotionSurveyStore.ts @@ -0,0 +1,72 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { SurveyAnswerKind } from "@/types/missions"; + +const EMOTION_SURVEY_COOLDOWN_MS = 24 * 60 * 60 * 1000; + +type EmotionSurveyState = { + lastAnsweredAt: number | null; + lastAnswerKind: SurveyAnswerKind | null; + markAnswered: (answerKind: SurveyAnswerKind, answeredAt?: number) => void; + resetIfExpired: () => void; + reset: () => void; +}; + +export const useEmotionSurveyStore = create()( + persist( + set => ({ + lastAnsweredAt: null, + lastAnswerKind: null, + + markAnswered: (answerKind, answeredAt = Date.now()) => + set(() => ({ + lastAnsweredAt: answeredAt, + lastAnswerKind: answerKind, + })), + + resetIfExpired: () => + set(state => { + if (!state.lastAnsweredAt) { + return state; + } + + /* + * 한글 주석: + * 홈 말풍선 노출 여부는 마지막 응답 시각 기준 24시간으로 계산한다. + * 유효 시간이 지나면 로컬 완료 상태를 비워 다음 질문 노출을 허용한다. + */ + if (Date.now() - state.lastAnsweredAt >= EMOTION_SURVEY_COOLDOWN_MS) { + return { + lastAnsweredAt: null, + lastAnswerKind: null, + }; + } + + return state; + }), + + reset: () => + set(() => ({ + lastAnsweredAt: null, + lastAnswerKind: null, + })), + }), + { + name: "emotion-survey", + storage: createJSONStorage(() => AsyncStorage), + partialize: state => ({ + lastAnsweredAt: state.lastAnsweredAt, + lastAnswerKind: state.lastAnswerKind, + }), + } + ) +); + +export const getEmotionSurveyCooldownActive = (lastAnsweredAt: number | null) => { + if (!lastAnsweredAt) { + return false; + } + + return Date.now() - lastAnsweredAt < EMOTION_SURVEY_COOLDOWN_MS; +}; diff --git a/src/stores/useHomeSummaryStore.ts b/src/stores/useHomeSummaryStore.ts index 1e03fe0..945740b 100644 --- a/src/stores/useHomeSummaryStore.ts +++ b/src/stores/useHomeSummaryStore.ts @@ -12,6 +12,7 @@ type HomeSummaryState = { user: UserInfo | null; gardens: GardenSummary[]; missions: TodayMission[]; + todayDiaryId: number | null; hydrate: (payload: HomeSummaryPayload) => void; updateGarden: (gardenId: number, patch: Partial) => void; @@ -33,6 +34,7 @@ const initialState: Omit< user: null, gardens: [], missions: [], + todayDiaryId: null, }; export const useHomeSummaryStore = create()( @@ -45,6 +47,7 @@ export const useHomeSummaryStore = create()( user: payload.userInfo, gardens: payload.gardenSummaries, missions: payload.todayMissions, + todayDiaryId: payload.todayDiaryId ?? null, })), updateGarden: (gardenId, patch) => @@ -69,6 +72,7 @@ export const useHomeSummaryStore = create()( user: state.user, gardens: state.gardens, missions: state.missions, + todayDiaryId: state.todayDiaryId, }), } ) diff --git a/src/stores/useRegistrationStore.ts b/src/stores/useRegistrationStore.ts new file mode 100644 index 0000000..addeca7 --- /dev/null +++ b/src/stores/useRegistrationStore.ts @@ -0,0 +1,60 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { + AvatarMaster, + RegistrationAvatarPreview, + RegistrationCreationDetail, + RegistrationMode, +} from "@/types/avatars"; + +type RegistrationState = { + mode: RegistrationMode | null; + creationDetail: RegistrationCreationDetail; + selectedMaster: AvatarMaster | null; + selectedPreview: RegistrationAvatarPreview | null; + nickname: string; + + setMode: (mode: RegistrationMode | null) => void; + updateCreationDetail: (patch: Partial) => void; + setSelectedMaster: (master: AvatarMaster | null) => void; + setSelectedPreview: (preview: RegistrationAvatarPreview | null) => void; + setNickname: (nickname: string) => void; + reset: () => void; +}; + +const initialCreationDetail: RegistrationCreationDetail = { + imageUri: "", + uploadedImageUrl: "", +}; + +const initialState = { + mode: null, + creationDetail: initialCreationDetail, + selectedMaster: null, + selectedPreview: null, + nickname: "", +}; + +const useRegistrationStore = create()( + persist( + set => ({ + ...initialState, + setMode: mode => set(state => ({ ...state, mode })), + updateCreationDetail: patch => + set(state => ({ + creationDetail: { ...state.creationDetail, ...patch }, + })), + setSelectedMaster: master => set(() => ({ selectedMaster: master })), + setSelectedPreview: preview => set(() => ({ selectedPreview: preview })), + setNickname: nickname => set(() => ({ nickname })), + reset: () => set(() => ({ ...initialState })), + }), + { + name: "registration-flow", + storage: createJSONStorage(() => AsyncStorage), + } + ) +); + +export default useRegistrationStore; diff --git a/src/stores/useTokenStore.ts b/src/stores/useTokenStore.ts index dfde0e5..c89c976 100644 --- a/src/stores/useTokenStore.ts +++ b/src/stores/useTokenStore.ts @@ -6,6 +6,11 @@ interface TokenStore { accessToken: string; refreshToken: string; userId: string; + setAuth: (payload: { + accessToken: string; + refreshToken: string; + userId: string; + }) => void; setAccessToken: (token: string) => void; setRefreshToken: (token: string) => void; setUserId: (id: string) => void; @@ -19,15 +24,22 @@ const useTokenStore = create()( accessToken: "", refreshToken: "", userId: "", + setAuth: ({ accessToken, refreshToken, userId }) => + set({ accessToken, refreshToken, userId }), setAccessToken: token => set({ accessToken: token }), setRefreshToken: token => set({ refreshToken: token }), setUserId: id => set({ userId: id }), - clearTokens: () => set({ accessToken: "", refreshToken: "" }), + clearTokens: () => set({ accessToken: "", refreshToken: "", userId: "" }), hasHydrated: false, }), { name: "napulnapul-token", storage: createJSONStorage(() => AsyncStorage), + partialize: state => ({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + userId: state.userId, + }), onRehydrateStorage: () => state => { if (state) state.hasHydrated = true; }, diff --git a/src/types/apis/register.ts b/src/types/apis/register.ts index 40f9987..ff2cf69 100644 --- a/src/types/apis/register.ts +++ b/src/types/apis/register.ts @@ -4,4 +4,5 @@ export type PostRegisterResponse = { userId: number; nickname: string; newUser: boolean; + requiresNicknameSetup: boolean; }; diff --git a/src/types/avatars/index.ts b/src/types/avatars/index.ts new file mode 100644 index 0000000..34d48f4 --- /dev/null +++ b/src/types/avatars/index.ts @@ -0,0 +1,42 @@ +export type RegistrationMode = "selection" | "creation"; + +export interface AvatarMaster { + id: number; + defaultImageUrl: string; + description: string; +} + +export interface SelectAvatarResponse { + isSuccess: boolean; + code: string; + message: string; + result: AvatarMaster[]; +} + +export interface UploadCreationAvatarResponse { + imageUrl: string; +} + +export interface FinalChoiceAvatarRequest { + nickname: string; + imageUrl: string; + masterId: number | null; +} + +export interface FinalChoiceAvatarResponse { + isSuccess: boolean; + code: string; + message: string; + result: null; +} + +export interface RegistrationCreationDetail { + imageUri: string; + uploadedImageUrl: string; +} + +export interface RegistrationAvatarPreview { + masterId: number | null; + description: string; + imageUrl: string | null; +} diff --git a/src/types/axios.d.ts b/src/types/axios.d.ts new file mode 100644 index 0000000..4bc6897 --- /dev/null +++ b/src/types/axios.d.ts @@ -0,0 +1,13 @@ +import "axios"; + +declare module "axios" { + interface AxiosRequestConfig { + _skipAuth?: boolean; + _retry?: boolean; + } + + interface InternalAxiosRequestConfig { + _skipAuth?: boolean; + _retry?: boolean; + } +} diff --git a/src/types/comments/commentApi.type.ts b/src/types/comments/commentApi.type.ts index 0ac8f32..adec340 100644 --- a/src/types/comments/commentApi.type.ts +++ b/src/types/comments/commentApi.type.ts @@ -6,6 +6,7 @@ export type PostCommentRequest = { export type PostCommentResponse = { id: number; + writerId: number; writer: string; content: string; targetId: number; diff --git a/src/types/delivery/index.ts b/src/types/delivery/index.ts new file mode 100644 index 0000000..5206f8c --- /dev/null +++ b/src/types/delivery/index.ts @@ -0,0 +1,15 @@ +export interface DeliverablePlant { + seedType: number; + imageUrl: string; + name: string; +} + +export interface CreateSeedDeliveryRequest { + seedType: number; + recipientName: string; + recipientPhone: string; + postalCode: string; + address: string; + addressDetail: string; + message?: string; +} diff --git a/src/types/feed/avatarPostDetailApi.type.ts b/src/types/feed/avatarPostDetailApi.type.ts index 6328402..c6f9edc 100644 --- a/src/types/feed/avatarPostDetailApi.type.ts +++ b/src/types/feed/avatarPostDetailApi.type.ts @@ -1,5 +1,6 @@ export type AvatarPostCommentApi = { commentId: number; + writerId: number | null; profileImageUrl: string | null; writer: string; content: string; diff --git a/src/types/feed/detail.ts b/src/types/feed/detail.ts index 84a07e2..a0516c6 100644 --- a/src/types/feed/detail.ts +++ b/src/types/feed/detail.ts @@ -1,5 +1,6 @@ export interface FeedComment { commentId: number; + writerId: number | null; profileImageUrl: string | null; writer: string; content: string; diff --git a/src/types/feed/randomFeedApi.type.ts b/src/types/feed/randomFeedApi.type.ts new file mode 100644 index 0000000..ce56cc1 --- /dev/null +++ b/src/types/feed/randomFeedApi.type.ts @@ -0,0 +1,32 @@ +export type RandomFeedPostType = "DIARY" | "AVATAR_POST"; + +export interface RandomFeedAuthor { + userId: number; + username: string; + profileImageUrl: string | null; +} + +export interface RandomFeedSessionItem { + postId: number; + postType: RandomFeedPostType; + author: RandomFeedAuthor; + likeCount: number; + commentCount: number; + createdAt: string; +} + +export interface RandomFeedSessionPayload { + sessionToken: string; + items: RandomFeedSessionItem[]; + hasMore: boolean; + remaining: number; +} + +export interface RandomFeedSessionStartRequest { + size: number; +} + +export interface RandomFeedSessionNextRequest { + sessionToken: string; + size: number; +} diff --git a/src/types/follow/index.ts b/src/types/follow/index.ts new file mode 100644 index 0000000..8eb7d12 --- /dev/null +++ b/src/types/follow/index.ts @@ -0,0 +1,9 @@ +export interface User { + userId: number; + username: string; + userImageUrl: string | null; +} + +export interface FollowResponse { + result: User[]; +} diff --git a/src/types/home/alerts.ts b/src/types/home/alerts.ts new file mode 100644 index 0000000..4753515 --- /dev/null +++ b/src/types/home/alerts.ts @@ -0,0 +1,16 @@ +export interface GuestbookEntry { + author: string; + content: string; + createdAt: string; +} + +export interface NotificationItem { + id: number; + content: string; + url: string | null; + thumbnailUrl: string | null; + isRead: boolean; + read?: boolean; + notificationType: string; + createdAt: string; +} diff --git a/src/types/home/garden.ts b/src/types/home/garden.ts index b3583b2..1a7ca0b 100644 --- a/src/types/home/garden.ts +++ b/src/types/home/garden.ts @@ -4,12 +4,16 @@ export interface Avatar { avatarImageUrl: string; } +export type HomeMissionType = "DIARY" | "QUIZ" | "CHECKING"; + export interface GardenSummary { gardenId: number; gardenSlotNumber: number; avatar?: Avatar | null; - locked: boolean; - unlockable: boolean; + isLocked?: boolean; + isUnlockable?: boolean; + locked?: boolean; + unlockable?: boolean; ownerWateringAble: boolean | null; ownerSunlightAble: boolean; } @@ -17,8 +21,9 @@ export interface GardenSummary { export interface TodayMission { missionId: number; missionTitle: string; - missionType: string; - completed: boolean; + missionType: HomeMissionType | string; + isCompleted?: boolean; + completed?: boolean; } export interface UserInfo { @@ -28,10 +33,36 @@ export interface UserInfo { currentExp: number; requiredExpForNextLevel: number; unreadNotificationCount: number; + lastAccessedSlotNumber?: number; } export interface HomeSummaryPayload { userInfo: UserInfo; gardenSummaries: GardenSummary[]; todayMissions: TodayMission[]; + todayDiaryId?: number | null; } + +export const getGardenLocked = (garden: GardenSummary) => + garden.isLocked ?? garden.locked ?? false; + +export const getGardenUnlockable = (garden: GardenSummary) => + garden.isUnlockable ?? garden.unlockable ?? false; + +export const getMissionCompleted = (mission: TodayMission) => + mission.isCompleted ?? mission.completed ?? false; + +export const normalizeHomeSummaryPayload = ( + payload: HomeSummaryPayload +): HomeSummaryPayload => ({ + ...payload, + gardenSummaries: payload.gardenSummaries.map(garden => ({ + ...garden, + isLocked: getGardenLocked(garden), + isUnlockable: getGardenUnlockable(garden), + })), + todayMissions: payload.todayMissions.map(mission => ({ + ...mission, + isCompleted: getMissionCompleted(mission), + })), +}); diff --git a/src/types/home/panel.ts b/src/types/home/panel.ts new file mode 100644 index 0000000..3290741 --- /dev/null +++ b/src/types/home/panel.ts @@ -0,0 +1,15 @@ +export interface HomePanelWishTree { + currentStage: string; + nextStage: string; + currentPoints: number; + requiredPointsForNextStage: number; + progressPercent: number; +} + +export interface HomePanelPayload { + isDairyCompleted: boolean; + isCheckingCompleted: boolean; + isQuizCompleted: boolean; + isQuizResultAvailable?: boolean; + wishTree: HomePanelWishTree; +} diff --git a/src/types/home/tracking.ts b/src/types/home/tracking.ts new file mode 100644 index 0000000..ff3a851 --- /dev/null +++ b/src/types/home/tracking.ts @@ -0,0 +1,13 @@ +export interface TrackingPromptStatusPayload { + eligible: boolean; + alreadyViewed: boolean; + perfectDayCount: number; + cycleKey: string; + windowStart: string; + windowEnd: string; + message: string; +} + +export interface TrackingPromptConfirmRequest { + cycleKey: string; +} diff --git a/src/types/log/diaryDetailApi.type.ts b/src/types/log/diaryDetailApi.type.ts index e6fedaf..e747c0a 100644 --- a/src/types/log/diaryDetailApi.type.ts +++ b/src/types/log/diaryDetailApi.type.ts @@ -1,5 +1,6 @@ export type DiaryCommentApi = { commentId: number; + writerId: number | null; profileImageUrl: string | null; writer: string; content: string; diff --git a/src/types/missions/index.ts b/src/types/missions/index.ts new file mode 100644 index 0000000..828907f --- /dev/null +++ b/src/types/missions/index.ts @@ -0,0 +1,122 @@ +export type MissionQuizType = "MULTI_CHOICE" | "OX"; +export type SurveyAnswerKind = "YES" | "NEUTRAL" | "NO"; +export type SurveyAnswerValue = 1 | 2 | 3; + +export const SURVEY_ANSWER_VALUE_MAP: Record = { + YES: 1, + NEUTRAL: 2, + NO: 3, +}; + +export interface DiaryImageUploadResult { + imageId: number; + imageUrl: string; +} + +export interface DiaryImageUploadResponse { + isSuccess: boolean; + code: string; + message: string; + result: DiaryImageUploadResult; +} + +export interface WriteDiaryRequest { + title: string; + content: string; + isPublic: boolean; + imageId: number; + imageUrl: string; +} + +export interface WriteDiaryResponse { + isSuccess: boolean; + code: string; + message: string; + result: string; +} + +export interface TodayKeyword { + keyword: string; +} + +export interface GetTodayKeywordResponse { + isSuccess: boolean; + code: string; + message: string; + result: TodayKeyword; +} + +export interface QuizOption { + optionOrder: number; + optionText: string; +} + +export interface MissionQuiz { + quizId: number; + quizQuestion: string; + quizType: MissionQuizType; + quizOptions?: QuizOption[] | null; + isCompleted?: boolean; + selectedOptionNumber?: number | null; + answerNumber?: number | null; + isCorrect?: boolean | null; + answerDescription?: string | null; +} + +export interface GetQuizRequest { + quizType: MissionQuizType; +} + +export interface GetQuizResponse { + isSuccess: boolean; + code: string; + message: string; + result: MissionQuiz; +} + +export interface AnswerQuizRequest { + quizId: number; + selectedOptionOrder: number; +} + +export interface AnswerQuizResult { + isCorrect: boolean; + answerDescription: string; + answerNumber: number; + isCompleted: boolean; + selectedOptionNumber: number; + quizQuestion: string; + quizType: MissionQuizType; +} + +export interface AnswerQuizResponse { + isSuccess: boolean; + code: string; + message: string; + result: AnswerQuizResult; +} + +export interface DailySurvey { + id: number; + question: string; + isAnswered: boolean; +} + +export interface GetDailySurveyResponse { + isSuccess: boolean; + code: string; + message: string; + result: DailySurvey; +} + +export interface AnswerDailySurveyRequest { + questionId: number; + answer: SurveyAnswerValue; +} + +export interface AnswerDailySurveyResponse { + isSuccess: boolean; + code: string; + message: string; + result: null; +} diff --git a/src/types/profile/guestbookApi.type.ts b/src/types/profile/guestbookApi.type.ts new file mode 100644 index 0000000..d02b6e2 --- /dev/null +++ b/src/types/profile/guestbookApi.type.ts @@ -0,0 +1,11 @@ +export type GuestbookEntry = { + author: string; + content: string; + createdAt: string; +}; + +export type CreateGuestbookRequest = { + content: string; +}; + +export type CreateGuestbookResponse = Record; diff --git a/src/types/profile/profileApi.type.ts b/src/types/profile/profileApi.type.ts new file mode 100644 index 0000000..c669853 --- /dev/null +++ b/src/types/profile/profileApi.type.ts @@ -0,0 +1,29 @@ +export type AvatarInfo = { + avatarId: number; + avatarName: string; + avatarImageUrl: string; +}; + +export type GardenInfo = { + gardenId: number; + avatarInfo: AvatarInfo | null; + isWateringAbleByMe: boolean; +}; + +export enum FollowStatus { + NOT_FOLLOWING = "NOT_FOLLOWING", + FOLLOWING = "FOLLOWING", + FOLLOW_BACK_POSSIBLE = "FOLLOW_BACK_POSSIBLE", +} + +export type GetUserProfileResponse = { + id: number; + userNickname: string; + profileImageUrl: string | null; + followStatus: FollowStatus; + leftWaterCountForOthers: number; + userGardens: GardenInfo[]; +}; + +export type FriendWaterResponse = string; +export type FollowUserResponse = string; diff --git a/src/types/report/index.ts b/src/types/report/index.ts new file mode 100644 index 0000000..d8f9177 --- /dev/null +++ b/src/types/report/index.ts @@ -0,0 +1,8 @@ +export type ReportTargetType = "DIARY" | "COMMENT" | "AVATAR_POST" | "USER"; + +export type CreateReportPayload = { + targetType: ReportTargetType; + targetId: number; + reason: string; + additionalComment?: string; +}; diff --git a/src/types/svg.d.ts b/src/types/svg.d.ts new file mode 100644 index 0000000..624fca2 --- /dev/null +++ b/src/types/svg.d.ts @@ -0,0 +1,6 @@ +declare module "*.svg" { + import React from "react"; + import { SvgProps } from "react-native-svg"; + const content: React.FC; + export default content; +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..86feaa8 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,33 @@ +import { supabase } from "@/apis/supabase"; +import useTokenStore from "@/stores/useTokenStore"; + +export const clearLocalSession = async () => { + useTokenStore.getState().clearTokens(); + + try { + await supabase.auth.signOut(); + } catch (error) { + console.error("[auth] Failed to clear Supabase session:", error); + } +}; + +export const logout = async () => { + const { accessToken } = useTokenStore.getState(); + + if (accessToken) { + const apiUrl = process.env.EXPO_PUBLIC_API_URL || "https://api.napulnapul.com"; + + try { + await fetch(`${apiUrl}/api/v1/notifications/token`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + } catch (error) { + console.error("[auth] Failed to delete notification token during logout:", error); + } + } + + await clearLocalSession(); +}; diff --git a/src/utils/debug.ts b/src/utils/debug.ts new file mode 100644 index 0000000..5ae429a --- /dev/null +++ b/src/utils/debug.ts @@ -0,0 +1,24 @@ +export function debugLog(scope: string, message: string, payload?: unknown) { + if (payload === undefined) { + console.debug(`[Debug][${scope}] ${message}`); + return; + } + + console.debug(`[Debug][${scope}] ${message}`, payload); +} + +export function debugScreenMounted(screenName: string, payload?: unknown) { + debugLog("Screen", `${screenName} mounted`, payload); +} + +export function createTimingLogger(scope: string, eventName: string, payload?: unknown) { + const startedAt = Date.now(); + debugLog(scope, `${eventName} started`, payload); + + return (extra?: Record) => { + debugLog(scope, `${eventName} finished`, { + durationMs: Date.now() - startedAt, + ...(extra ?? {}), + }); + }; +} diff --git a/src/utils/fcm.ts b/src/utils/fcm.ts new file mode 100644 index 0000000..3d3a8b6 --- /dev/null +++ b/src/utils/fcm.ts @@ -0,0 +1,71 @@ +import * as Notifications from "expo-notifications"; +import * as Device from "expo-device"; +import { Platform } from "react-native"; +import { deleteFcmToken, postFcmToken } from "@/apis/option/notificationApi"; +import { debugLog } from "@/utils/debug"; + +const ensureNotificationPermission = async (): Promise => { + if (!Device.isDevice) { + debugLog("FCM", "skipped - not a physical device"); + return false; + } + + const { granted: existing } = await Notifications.getPermissionsAsync(); + + if (existing) { + return true; + } + + const { granted } = await Notifications.requestPermissionsAsync(); + + if (!granted) { + debugLog("FCM", "notification permission denied"); + return false; + } + + return true; +}; + +const configureAndroidNotificationChannel = async () => { + if (Platform.OS !== "android") { + return; + } + + await Notifications.setNotificationChannelAsync("default", { + name: "나풀나풀", + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + }); +}; + +export const registerDeviceFcmToken = async (): Promise => { + try { + const hasPermission = await ensureNotificationPermission(); + if (!hasPermission) { + return false; + } + + await configureAndroidNotificationChannel(); + + const { data: token } = await Notifications.getDevicePushTokenAsync(); + debugLog("FCM", "device token acquired"); + + await postFcmToken(token); + debugLog("FCM", "token registered with server"); + return true; + } catch (error) { + debugLog("FCM", "token registration failed", { error }); + return false; + } +}; + +export const unregisterDeviceFcmToken = async (): Promise => { + try { + await deleteFcmToken(); + debugLog("FCM", "token removed from server"); + return true; + } catch (error) { + debugLog("FCM", "token removal failed", { error }); + return false; + } +}; diff --git a/ts_errors.log b/ts_errors.log new file mode 100644 index 0000000..eeed1be Binary files /dev/null and b/ts_errors.log differ diff --git a/ts_final.txt b/ts_final.txt new file mode 100644 index 0000000..4c3691f --- /dev/null +++ b/ts_final.txt @@ -0,0 +1,28 @@ +src/assets/icons/common/index.ts(1,37): error TS2307: Cannot find module './bookmark1.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(2,43): error TS2307: Cannot find module './bookmark2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(3,43): error TS2307: Cannot find module './calendar1.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(4,37): error TS2307: Cannot find module './calendar2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(5,35): error TS2307: Cannot find module './camera.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(6,33): error TS2307: Cannot find module './chat.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(7,34): error TS2307: Cannot find module './Check.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(8,36): error TS2307: Cannot find module './Check2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(9,33): error TS2307: Cannot find module './edit.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(10,34): error TS2307: Cannot find module './heart.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(11,39): error TS2307: Cannot find module './home1.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(12,33): error TS2307: Cannot find module './home2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(13,33): error TS2307: Cannot find module './left.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(14,35): error TS2307: Cannot find module './level0.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(15,35): error TS2307: Cannot find module './level1.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(16,35): error TS2307: Cannot find module './level2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(17,35): error TS2307: Cannot find module './level3.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(18,34): error TS2307: Cannot find module './right.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(19,41): error TS2307: Cannot find module './search1.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(20,35): error TS2307: Cannot find module './search2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(21,33): error TS2307: Cannot find module './send.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(22,36): error TS2307: Cannot find module './togle1.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(23,37): error TS2307: Cannot find module './togle2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(24,39): error TS2307: Cannot find module './user1.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(25,33): error TS2307: Cannot find module './user2.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(26,37): error TS2307: Cannot find module './userPlus.svg?react' or its corresponding type declarations. +src/assets/icons/common/index.ts(27,34): error TS2307: Cannot find module './Xmack.svg?react' or its corresponding type declarations. +src/pages/follow/FollowScreen.tsx(4,33): error TS2344: Type '"Follow"' does not satisfy the constraint 'keyof MainTabParamList'.