diff --git a/apps/admin/package.json b/apps/admin/package.json index 6dcd995..7680d32 100755 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -44,8 +44,8 @@ "tailwind-merge": "^2.2.0" }, "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "~19.1.10", + "@types/react-dom": "~19.1.7", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.16", "postcss": "^8.4.32", diff --git a/apps/api/drizzle/0013_dares_loop.sql b/apps/api/drizzle/0013_dares_loop.sql new file mode 100644 index 0000000..f75c178 --- /dev/null +++ b/apps/api/drizzle/0013_dares_loop.sql @@ -0,0 +1,223 @@ +-- Dares loop: catalogue review parity, durable dare instances, and outbox notifications. +-- +-- Three concerns land together because the send path depends on all of them: +-- 1. `content_status` parity with the legacy catalogue so unreviewed dares stay hidden. +-- 2. Text snapshots on `sent_dares` so catalogue edits never rewrite a couple's history. +-- 3. Outbox rows for every status transition, which is the whole retention argument. + +-- `to_regtype` resolves through the active search_path, so this creates the type in +-- the current schema rather than skipping because an unrelated schema already has it. +DO $$ +BEGIN + IF to_regtype('content_review_status') IS NULL THEN + CREATE TYPE content_review_status AS ENUM ('unreviewed', 'allowed', 'archived'); + END IF; +END $$; +--> statement-breakpoint +ALTER TABLE dare_packs + ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed', + ADD COLUMN IF NOT EXISTS content_review_reason text, + ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz, + ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL; +--> statement-breakpoint +ALTER TABLE dares + ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed', + ADD COLUMN IF NOT EXISTS content_review_reason text, + ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz, + ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL; +--> statement-breakpoint +COMMENT ON COLUMN dare_packs.content_status IS + 'Store-safety review state. Only `allowed` packs are exposed to the mobile catalogue.'; +--> statement-breakpoint +COMMENT ON COLUMN dares.content_status IS + 'Store-safety review state. Only `allowed` dares are sendable.'; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS dare_packs_content_status_idx + ON dare_packs (content_status, is_public, sort_order); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS dares_content_status_idx + ON dares (pack_id, content_status, intensity); +--> statement-breakpoint + +-- `sent_dares.dare_id` is ON DELETE SET NULL, but `sent_dares_dare_or_custom_check` +-- requires one of the two source columns to survive. Deleting a dare that had ever +-- been sent therefore aborted with a check violation. Snapshotting the text at send +-- time removes the dependency entirely and keeps history stable across content edits. +ALTER TABLE sent_dares + ADD COLUMN IF NOT EXISTS dare_text_snapshot text, + ADD COLUMN IF NOT EXISTS dare_intensity_snapshot integer, + ADD COLUMN IF NOT EXISTS submitted_at timestamptz, + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); +--> statement-breakpoint +UPDATE sent_dares sd +SET dare_text_snapshot = COALESCE(sd.custom_dare_text, d.text), + dare_intensity_snapshot = COALESCE(sd.custom_dare_intensity, d.intensity) +FROM dares d +WHERE d.id = sd.dare_id AND sd.dare_text_snapshot IS NULL; +--> statement-breakpoint +UPDATE sent_dares +SET dare_text_snapshot = custom_dare_text, + dare_intensity_snapshot = COALESCE(custom_dare_intensity, 1) +WHERE dare_text_snapshot IS NULL AND custom_dare_text IS NOT NULL; +--> statement-breakpoint +ALTER TABLE sent_dares + ALTER COLUMN dare_text_snapshot SET NOT NULL, + ALTER COLUMN dare_intensity_snapshot SET NOT NULL; +--> statement-breakpoint +ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_dare_or_custom_check; +--> statement-breakpoint +ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_intensity_snapshot_check; +--> statement-breakpoint +ALTER TABLE sent_dares + ADD CONSTRAINT sent_dares_intensity_snapshot_check + CHECK (dare_intensity_snapshot BETWEEN 1 AND 5); +--> statement-breakpoint +ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_participants_distinct_check; +--> statement-breakpoint +ALTER TABLE sent_dares + ADD CONSTRAINT sent_dares_participants_distinct_check + CHECK (sender_id <> recipient_id); +--> statement-breakpoint + +-- `submitted` closes the loop the original design left open: the recipient performs +-- the dare, so the recipient reports it done and the sender confirms. +ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_status_check; +--> statement-breakpoint +ALTER TABLE sent_dares + ADD CONSTRAINT sent_dares_status_check CHECK ( + status IN ('pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled') + ); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS sent_dares_recipient_status_idx ON sent_dares(recipient_id, status); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS sent_dares_couple_status_idx ON sent_dares(couple_id, status, sent_at DESC); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS sent_dares_sender_sent_at_idx ON sent_dares(sender_id, sent_at DESC); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS sent_dares_expiry_idx + ON sent_dares(expires_at) WHERE status IN ('pending', 'active', 'submitted'); +--> statement-breakpoint +ALTER TABLE notification_preferences + ADD COLUMN IF NOT EXISTS dares_enabled boolean NOT NULL DEFAULT true; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION touch_sent_dare() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END $$; +--> statement-breakpoint +DROP TRIGGER IF EXISTS sent_dares_touch ON sent_dares; +--> statement-breakpoint +CREATE TRIGGER sent_dares_touch BEFORE UPDATE ON sent_dares +FOR EACH ROW EXECUTE FUNCTION touch_sent_dare(); +--> statement-breakpoint + +-- Push copy is deliberately generic. Store compliance forbids dare, question, +-- response, or message text in a notification preview. +CREATE OR REPLACE FUNCTION queue_dare_operations() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + v_recipients uuid[]; + v_recipient uuid; + v_title text; + v_body text; + v_event text; +BEGIN + IF current_setting('sauci.suppress_operations', true) = 'on' THEN RETURN NEW; END IF; + + IF TG_OP = 'INSERT' THEN + v_event := 'sent'; + v_recipients := ARRAY[NEW.recipient_id]; + v_title := 'New dare'; + v_body := 'Your partner sent you a dare'; + ELSIF NEW.status IS DISTINCT FROM OLD.status THEN + v_event := NEW.status; + CASE NEW.status + WHEN 'active' THEN + v_recipients := ARRAY[NEW.sender_id]; + v_title := 'Dare accepted'; + v_body := 'Your partner accepted your dare'; + WHEN 'declined' THEN + v_recipients := ARRAY[NEW.sender_id]; + v_title := 'Dare declined'; + v_body := 'Your partner passed on your dare'; + WHEN 'submitted' THEN + v_recipients := ARRAY[NEW.sender_id]; + v_title := 'Dare done'; + v_body := 'Your partner says they completed your dare'; + WHEN 'completed' THEN + v_recipients := ARRAY[NEW.recipient_id]; + v_title := 'Dare complete'; + v_body := 'Your partner confirmed your dare'; + WHEN 'cancelled' THEN + v_recipients := ARRAY[NEW.recipient_id]; + v_title := 'Dare cancelled'; + v_body := 'Your partner cancelled the dare'; + WHEN 'expired' THEN + -- Both sides lose the dare, so both sides hear about it. + v_recipients := ARRAY[NEW.sender_id, NEW.recipient_id]; + v_title := 'Dare expired'; + v_body := 'A dare ran out of time'; + ELSE + RETURN NEW; + END CASE; + ELSE + RETURN NEW; + END IF; + + FOREACH v_recipient IN ARRAY v_recipients LOOP + CONTINUE WHEN NOT EXISTS ( + SELECT 1 FROM profiles p + LEFT JOIN notification_preferences np ON np.user_id = p.id + WHERE p.id = v_recipient AND COALESCE(np.dares_enabled, true) = true + ); + + INSERT INTO operations_outbox(kind, dedupe_key, recipient_id, payload) + VALUES ( + 'expo', + 'dare:' || NEW.id || ':' || v_event || ':' || v_recipient, + v_recipient, + jsonb_build_object( + 'title', v_title, + 'body', v_body, + 'data', jsonb_build_object('type', 'dare', 'event', v_event, 'sent_dare_id', NEW.id) + ) + ) + ON CONFLICT (dedupe_key) DO NOTHING; + END LOOP; + + RETURN NEW; +END $$; +--> statement-breakpoint +DROP TRIGGER IF EXISTS operations_dare_changed ON sent_dares; +--> statement-breakpoint +CREATE TRIGGER operations_dare_changed AFTER INSERT OR UPDATE ON sent_dares +FOR EACH ROW EXECUTE FUNCTION queue_dare_operations(); + +--> statement-breakpoint +-- Mirror the review decision already enforced on the legacy catalogue +-- (20260828114256_enforce_allowed_catalogue): "Romantic Gestures" passed store-safety +-- review, the other seeded packs did not. Only rows still `unreviewed` are touched, so +-- this never overrides a later decision made in the admin portal. +UPDATE dare_packs +SET content_status = 'allowed', + content_review_reason = 'Reviewed as non-sexual romantic gestures' +WHERE id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid AND content_status = 'unreviewed'; +--> statement-breakpoint +UPDATE dare_packs +SET content_status = 'archived', + content_review_reason = 'Pack contains suggestive, sexual, dominance, or fantasy dares' +WHERE id IN ( + 'a1b2c3d4-2222-2222-2222-222222222222'::uuid, + 'a1b2c3d4-3333-3333-3333-333333333333'::uuid, + 'a1b2c3d4-4444-4444-4444-444444444444'::uuid +) AND content_status = 'unreviewed'; +--> statement-breakpoint +UPDATE dares d +SET content_status = dp.content_status, + content_review_reason = CASE + WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack' + ELSE 'Archived with non-store-safe dare pack' + END +FROM dare_packs dp +WHERE d.pack_id = dp.id AND d.content_status = 'unreviewed' AND dp.content_status <> 'unreviewed'; diff --git a/apps/api/drizzle/0013_daily_limit_local_reset.sql b/apps/api/drizzle/0014_daily_limit_local_reset.sql similarity index 100% rename from apps/api/drizzle/0013_daily_limit_local_reset.sql rename to apps/api/drizzle/0014_daily_limit_local_reset.sql diff --git a/apps/api/drizzle/0014_daily_limit_response_index.sql b/apps/api/drizzle/0015_daily_limit_response_index.sql similarity index 100% rename from apps/api/drizzle/0014_daily_limit_response_index.sql rename to apps/api/drizzle/0015_daily_limit_response_index.sql diff --git a/apps/api/drizzle/0015_couple_streak_locality.sql b/apps/api/drizzle/0016_couple_streak_locality.sql similarity index 100% rename from apps/api/drizzle/0015_couple_streak_locality.sql rename to apps/api/drizzle/0016_couple_streak_locality.sql diff --git a/apps/api/drizzle/0016_streak_reminder_preference.sql b/apps/api/drizzle/0017_streak_reminder_preference.sql similarity index 100% rename from apps/api/drizzle/0016_streak_reminder_preference.sql rename to apps/api/drizzle/0017_streak_reminder_preference.sql diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index a3b7e0c..cf3238f 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -96,29 +96,36 @@ { "idx": 13, "version": "7", - "when": 1787994900000, - "tag": "0013_daily_limit_local_reset", + "when": 1787909400000, + "tag": "0013_dares_loop", "breakpoints": true }, { "idx": 14, "version": "7", - "when": 1787994960000, - "tag": "0014_daily_limit_response_index", + "when": 1787994900000, + "tag": "0014_daily_limit_local_reset", "breakpoints": true }, { "idx": 15, "version": "7", - "when": 1788081360000, - "tag": "0015_couple_streak_locality", + "when": 1787994960000, + "tag": "0015_daily_limit_response_index", "breakpoints": true }, { "idx": 16, "version": "7", + "when": 1788081360000, + "tag": "0016_couple_streak_locality", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", "when": 1788081420000, - "tag": "0016_streak_reminder_preference", + "tag": "0017_streak_reminder_preference", "breakpoints": true } ] diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 584fda6..55f6ac8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js'; import { registerAnswerRoutes } from './domains/answers/routes.js'; import type { ChatRepository } from './domains/chat/repository.js'; import { registerChatRoutes } from './domains/chat/routes.js'; +import type { DaresRepository } from './domains/dares/repository.js'; +import { registerDareRoutes } from './domains/dares/routes.js'; import { registerCoupleRoutes } from './domains/couples/routes.js'; import type { CoupleService } from './domains/couples/service.js'; import type { PacksRepository } from './domains/packs/repository.js'; @@ -35,6 +37,7 @@ export interface AppDependencies { packsRepository?: PacksRepository; answersRepository?: AnswersRepository; chatRepository?: ChatRepository; + daresRepository?: DaresRepository; profileSettingsRepository?: ProfileSettingsRepository; accountOperationsService?: AccountOperationsService; mediaRepository?: MediaRepository; @@ -109,6 +112,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository); if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository); if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository); + if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository); if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository); if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService); if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage); diff --git a/apps/api/src/domains/dares/repository.ts b/apps/api/src/domains/dares/repository.ts new file mode 100644 index 0000000..c3b04ab --- /dev/null +++ b/apps/api/src/domains/dares/repository.ts @@ -0,0 +1,480 @@ +import { Pool, type PoolClient, type QueryResultRow } from 'pg'; + +export type DareStatus = + | 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled'; + +/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */ +export const FREE_WEEKLY_SEND_LIMIT = 3; + +/** Durations offered in the send sheet. `null` means no deadline. */ +export const DURATION_PRESET_HOURS = [1, 6, 12, 24, 72, 168] as const; + +export interface DarePack { + id: string; + name: string; + description: string | null; + icon: string | null; + is_premium: boolean; + is_explicit: boolean; + sort_order: number; + category_id: string | null; + min_intensity: number | null; + max_intensity: number | null; + avg_intensity: number | null; + dare_count: number; +} + +export interface DareItem { + id: string; + pack_id: string; + text: string; + intensity: number; + suggested_duration_hours: number | null; +} + +export interface SentDare { + id: string; + couple_id: string; + dare_id: string | null; + text: string; + intensity: number; + is_custom: boolean; + sender_id: string; + recipient_id: string; + direction: 'incoming' | 'outgoing'; + status: DareStatus; + sender_notes: string | null; + sent_at: string; + accepted_at: string | null; + submitted_at: string | null; + completed_at: string | null; + expires_at: string | null; +} + +export interface DareEntitlement { + is_premium: boolean; + can_send_custom: boolean; + weekly_send_limit: number | null; + sends_remaining: number | null; +} + +export interface DareCatalog { + entitlement: DareEntitlement; + packs: DarePack[]; +} + +export interface DareStats { + sent: number; + received: number; + completed_together: number; + active: number; + completed_by_me: number; + completed_by_partner: number; +} + +export interface SendDareInput { + dare_id?: string | null; + custom_dare_text?: string | null; + custom_dare_intensity?: number | null; + duration_hours?: number | null; + sender_notes?: string | null; +} + +export class DaresError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly status: 400 | 402 | 403 | 404 | 409, + ) { + super(message); + } +} + +interface ContextRow extends QueryResultRow { + couple_id: string | null; + is_premium: boolean; + hide_nsfw: boolean; + max_intensity: number; + partner_id: string | null; + partner_premium: boolean; +} + +interface SentDareRecord extends QueryResultRow { + id: string; + couple_id: string; + dare_id: string | null; + dare_text_snapshot: string; + dare_intensity_snapshot: number; + custom_dare_text: string | null; + sender_id: string; + recipient_id: string; + status: DareStatus; + sender_notes: string | null; + sent_at: Date; + accepted_at: Date | null; + submitted_at: Date | null; + completed_at: Date | null; + expires_at: Date | null; +} + +export interface DaresRepository { + getCatalog(userId: string): Promise; + listPackDares(userId: string, packId: string): Promise; + listDares(userId: string, filter: 'active' | 'history'): Promise; + send(userId: string, input: SendDareInput): Promise; + respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise; + submit(userId: string, dareId: string): Promise; + complete(userId: string, dareId: string): Promise; + cancel(userId: string, dareId: string): Promise; + stats(userId: string): Promise; + close(): Promise; +} + +const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot, + custom_dare_text, sender_id, recipient_id, status, sender_notes, + sent_at, accepted_at, submitted_at, completed_at, expires_at`; + +const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', '); + +/** Statuses still awaiting somebody's action; everything else is history. */ +const OPEN_STATUSES: readonly DareStatus[] = ['pending', 'active', 'submitted']; + +function iso(value: Date | null): string | null { + return value?.toISOString() ?? null; +} + +async function transaction(pool: Pool, operation: (client: PoolClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query('begin'); + const result = await operation(client); + await client.query('commit'); + return result; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } +} + +function toSentDare(row: SentDareRecord, viewerId: string): SentDare { + return { + id: row.id, + couple_id: row.couple_id, + dare_id: row.dare_id, + text: row.dare_text_snapshot, + intensity: row.dare_intensity_snapshot, + is_custom: row.custom_dare_text !== null, + sender_id: row.sender_id, + recipient_id: row.recipient_id, + direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing', + status: row.status, + sender_notes: row.sender_notes, + sent_at: row.sent_at.toISOString(), + accepted_at: iso(row.accepted_at), + submitted_at: iso(row.submitted_at), + completed_at: iso(row.completed_at), + expires_at: iso(row.expires_at), + }; +} + +export class PostgresDaresRepository implements DaresRepository { + private readonly pool: Pool; + + constructor(databaseUrl: string) { + this.pool = new Pool({ connectionString: databaseUrl }); + } + + /** + * Premium is couple-shared, matching the answers domain: either partner paying + * unlocks the catalogue for both. + */ + private async context(userId: string): Promise { + const result = await this.pool.query( + `select p.couple_id, p.is_premium, p.hide_nsfw, p.max_intensity, + partner.id as partner_id, + coalesce(partner.is_premium, false) as partner_premium + from profiles p + left join lateral ( + select id, is_premium from profiles other + where other.couple_id = p.couple_id and other.id <> p.id + order by other.id limit 1 + ) partner on true + where p.id = $1`, + [userId], + ); + const row = result.rows[0]; + if (!row) throw new DaresError('profile_not_found', 'Profile not found', 404); + return row; + } + + private static isPremium(context: ContextRow): boolean { + return context.is_premium || context.partner_premium; + } + + private async sendsThisWeek(userId: string, client: Pool | PoolClient = this.pool): Promise { + const result = await client.query<{ count: string }>( + `select count(*) as count from sent_dares + where sender_id = $1 and sent_at > now() - interval '7 days'`, + [userId], + ); + return Number(result.rows[0]?.count ?? 0); + } + + private async entitlement(context: ContextRow, userId: string): Promise { + const premium = PostgresDaresRepository.isPremium(context); + if (premium) { + return { is_premium: true, can_send_custom: true, weekly_send_limit: null, sends_remaining: null }; + } + const used = await this.sendsThisWeek(userId); + return { + is_premium: false, + can_send_custom: false, + weekly_send_limit: FREE_WEEKLY_SEND_LIMIT, + sends_remaining: Math.max(0, FREE_WEEKLY_SEND_LIMIT - used), + }; + } + + async getCatalog(userId: string): Promise { + const context = await this.context(userId); + const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity; + const [entitlement, packs] = await Promise.all([ + this.entitlement(context, userId), + this.pool.query( + `select dp.id, dp.name, dp.description, dp.icon, dp.is_premium, dp.is_explicit, + dp.sort_order, dp.category_id, dp.min_intensity, dp.max_intensity, + dp.avg_intensity::double precision as avg_intensity, + count(d.id) filter (where d.content_status = 'allowed')::integer as dare_count + from dare_packs dp + left join categories c on c.id = dp.category_id + left join dares d on d.pack_id = dp.id + where dp.is_public = true + and dp.content_status = 'allowed' + and (dp.category_id is null or c.is_public = true) + and ($1::boolean = false or dp.is_explicit = false) + and (dp.min_intensity is null or dp.min_intensity <= $2::integer) + group by dp.id + having count(d.id) filter (where d.content_status = 'allowed') > 0 + order by dp.sort_order, dp.name, dp.id`, + [context.hide_nsfw, maxIntensity], + ), + ]); + return { entitlement, packs: packs.rows }; + } + + async listPackDares(userId: string, packId: string): Promise { + const context = await this.context(userId); + const pack = await this.pool.query<{ is_premium: boolean; is_explicit: boolean }>( + `select dp.is_premium, dp.is_explicit + from dare_packs dp + left join categories c on c.id = dp.category_id + where dp.id = $1 and dp.is_public = true and dp.content_status = 'allowed' + and (dp.category_id is null or c.is_public = true)`, + [packId], + ); + const row = pack.rows[0]; + if (!row) throw new DaresError('pack_not_found', 'Dare pack not found', 404); + if (row.is_explicit && context.hide_nsfw) { + throw new DaresError('pack_not_found', 'Dare pack not found', 404); + } + if (row.is_premium && !PostgresDaresRepository.isPremium(context)) { + throw new DaresError('premium_required', 'This dare pack requires premium', 402); + } + const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity; + const result = await this.pool.query( + `select d.id, d.pack_id, d.text, d.intensity, d.suggested_duration_hours + from dares d + where d.pack_id = $1 and d.content_status = 'allowed' and d.intensity <= $2::integer + order by d.intensity, d.created_at, d.id`, + [packId, maxIntensity], + ); + return result.rows; + } + + async listDares(userId: string, filter: 'active' | 'history'): Promise { + const context = await this.context(userId); + if (!context.couple_id) return []; + const result = await this.pool.query( + `select ${SENT_COLUMNS} + from sent_dares sd + where sd.couple_id = $1 + and (sd.status = any($2::text[])) = $3::boolean + order by sd.sent_at desc, sd.id desc + limit 200`, + [context.couple_id, OPEN_STATUSES, filter === 'active'], + ); + return result.rows.map((row) => toSentDare(row, userId)); + } + + async send(userId: string, input: SendDareInput): Promise { + const context = await this.context(userId); + if (!context.couple_id || !context.partner_id) { + throw new DaresError('no_couple', 'Pair with your partner before sending dares', 409); + } + const premium = PostgresDaresRepository.isPremium(context); + + const duration = input.duration_hours ?? null; + if (duration !== null && !DURATION_PRESET_HOURS.includes(duration as never)) { + throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400); + } + + let text: string; + let intensity: number; + let dareId: string | null = null; + let customText: string | null = null; + let customIntensity: number | null = null; + + if (input.dare_id) { + // Re-check entitlement against the pack so a stale client cannot send a locked dare. + const dare = await this.pool.query<{ text: string; intensity: number; is_premium: boolean; is_explicit: boolean }>( + `select d.text, d.intensity, dp.is_premium, dp.is_explicit + from dares d + join dare_packs dp on dp.id = d.pack_id + left join categories c on c.id = dp.category_id + where d.id = $1 and d.content_status = 'allowed' and dp.content_status = 'allowed' + and dp.is_public = true and (dp.category_id is null or c.is_public = true)`, + [input.dare_id], + ); + const row = dare.rows[0]; + if (!row) throw new DaresError('dare_not_found', 'Dare not found', 404); + if (row.is_explicit && context.hide_nsfw) { + throw new DaresError('dare_not_found', 'Dare not found', 404); + } + if (row.is_premium && !premium) { + throw new DaresError('premium_required', 'This dare requires premium', 402); + } + dareId = input.dare_id; + text = row.text; + intensity = row.intensity; + } else { + if (!premium) throw new DaresError('premium_required', 'Custom dares require premium', 402); + const trimmed = input.custom_dare_text?.trim(); + if (!trimmed) throw new DaresError('invalid_dare', 'Custom dare text is required', 400); + customText = trimmed; + customIntensity = input.custom_dare_intensity ?? 1; + text = trimmed; + intensity = customIntensity; + } + + // The quota is a paywall boundary, so the count and the insert have to be atomic: + // locking the sender's profile row serializes concurrent sends by the same user. + return transaction(this.pool, async (client) => { + if (!premium) { + await client.query('select 1 from profiles where id = $1 for update', [userId]); + const used = await this.sendsThisWeek(userId, client); + if (used >= FREE_WEEKLY_SEND_LIMIT) { + throw new DaresError('send_limit_reached', 'Weekly dare limit reached', 402); + } + } + + const result = await client.query( + `insert into sent_dares ( + couple_id, dare_id, custom_dare_text, custom_dare_intensity, + dare_text_snapshot, dare_intensity_snapshot, + sender_id, recipient_id, sender_notes, expires_at + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, + case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end + ) + returning ${BARE_COLUMNS}`, + [ + context.couple_id, dareId, customText, customIntensity, text, intensity, + userId, context.partner_id, input.sender_notes?.trim() || null, duration, + ], + ); + return toSentDare(result.rows[0]!, userId); + }); + } + + /** + * Applies a status change, enforcing both the actor's role and the legal + * transitions out of the current status. Notifications are emitted by the + * `operations_dare_changed` trigger, so no enqueue happens here. + */ + private async transition( + userId: string, + dareId: string, + actor: 'sender' | 'recipient', + from: readonly DareStatus[], + to: DareStatus, + timestampColumn: 'accepted_at' | 'submitted_at' | 'completed_at' | null, + ): Promise { + const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id'; + const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : ''; + const result = await this.pool.query( + `update sent_dares sd + set status = $3::text${setTimestamp} + where sd.id = $1 and sd.${actorColumn} = $2 and sd.status = any($4::text[]) + returning ${SENT_COLUMNS}`, + [dareId, userId, to, from], + ); + const row = result.rows[0]; + if (row) return toSentDare(row, userId); + + // Distinguish "not yours / gone" from "wrong state" so the client can react. + const existing = await this.pool.query<{ sender_id: string; recipient_id: string; status: DareStatus }>( + 'select sender_id, recipient_id, status from sent_dares where id = $1', + [dareId], + ); + const found = existing.rows[0]; + if (!found || (found.sender_id !== userId && found.recipient_id !== userId)) { + throw new DaresError('dare_not_found', 'Dare not found', 404); + } + const actorId = actor === 'sender' ? found.sender_id : found.recipient_id; + if (actorId !== userId) { + throw new DaresError('not_permitted', `Only the ${actor} can do that`, 403); + } + throw new DaresError('invalid_transition', `A ${found.status} dare cannot change to ${to}`, 409); + } + + async respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise { + return action === 'accept' + ? this.transition(userId, dareId, 'recipient', ['pending'], 'active', 'accepted_at') + : this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null); + } + + async submit(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at'); + } + + async complete(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at'); + } + + async cancel(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null); + } + + async stats(userId: string): Promise { + const context = await this.context(userId); + if (!context.couple_id) { + return { sent: 0, received: 0, completed_together: 0, active: 0, completed_by_me: 0, completed_by_partner: 0 }; + } + const result = await this.pool.query>( + `select + count(*) filter (where sender_id = $2)::text as sent, + count(*) filter (where recipient_id = $2)::text as received, + count(*) filter (where status = 'completed')::text as completed_together, + count(*) filter (where status = any($3::text[]))::text as active, + count(*) filter (where status = 'completed' and recipient_id = $2)::text as completed_by_me, + count(*) filter (where status = 'completed' and sender_id = $2)::text as completed_by_partner + from sent_dares where couple_id = $1`, + [context.couple_id, userId, OPEN_STATUSES], + ); + const row = result.rows[0] ?? {}; + const read = (key: string): number => Number(row[key] ?? 0); + return { + sent: read('sent'), + received: read('received'), + completed_together: read('completed_together'), + active: read('active'), + completed_by_me: read('completed_by_me'), + completed_by_partner: read('completed_by_partner'), + }; + } + + async close(): Promise { + await this.pool.end(); + } +} diff --git a/apps/api/src/domains/dares/routes.ts b/apps/api/src/domains/dares/routes.ts new file mode 100644 index 0000000..970b83d --- /dev/null +++ b/apps/api/src/domains/dares/routes.ts @@ -0,0 +1,110 @@ +import type { ApiErrorResponse } from '@sauci/shared'; +import type { Hono } from 'hono'; +import { z } from 'zod'; +import type { AuthIdentity } from '../../auth.js'; +import { DURATION_PRESET_HOURS, DaresError, type DaresRepository } from './repository.js'; + +type DaresApp = Hono<{ Variables: { identity: AuthIdentity } }>; + +const id = z.string().uuid(); + +const sendBody = z + .object({ + dare_id: id.nullish(), + custom_dare_text: z.string().trim().min(1).max(500).nullish(), + custom_dare_intensity: z.number().int().min(1).max(5).nullish(), + duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(), + sender_notes: z.string().trim().max(500).nullish(), + }) + .strict() + .refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), { + message: 'Provide either dare_id or custom_dare_text', + }); + +const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict(); + +function error(code: string, message: string): ApiErrorResponse { + return { error: { code, message } }; +} + +async function result(operation: () => Promise) { + try { + return { ok: true as const, value: await operation() }; + } catch (cause) { + if (cause instanceof DaresError) return { ok: false as const, cause }; + throw cause; + } +} + +export function registerDareRoutes(app: DaresApp, repository: DaresRepository): void { + app.get('/v1/dares/durations', (c) => c.json({ presets: DURATION_PRESET_HOURS })); + + app.get('/v1/dares/packs', async (c) => { + const response = await result(() => repository.getCatalog(c.get('identity').id)); + return response.ok + ? c.json(response.value) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.get('/v1/dares/packs/:packId/dares', async (c) => { + const packId = id.safeParse(c.req.param('packId')); + if (!packId.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400); + const response = await result(() => repository.listPackDares(c.get('identity').id, packId.data)); + return response.ok + ? c.json({ dares: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.get('/v1/dares', async (c) => { + const filter = c.req.query('filter') ?? 'active'; + if (filter !== 'active' && filter !== 'history') { + return c.json(error('invalid_filter', 'Filter must be active or history'), 400); + } + const response = await result(() => repository.listDares(c.get('identity').id, filter)); + return response.ok + ? c.json({ dares: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.get('/v1/dares/stats', async (c) => { + const response = await result(() => repository.stats(c.get('identity').id)); + return response.ok + ? c.json(response.value) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.post('/v1/dares', async (c) => { + const body = sendBody.safeParse(await c.req.json().catch(() => ({}))); + if (!body.success) return c.json(error('invalid_dare', 'Provide either a dare_id or custom dare text'), 400); + const response = await result(() => repository.send(c.get('identity').id, body.data)); + return response.ok + ? c.json({ dare: response.value }, 201) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.post('/v1/dares/:dareId/respond', async (c) => { + const dareId = id.safeParse(c.req.param('dareId')); + const body = respondBody.safeParse(await c.req.json().catch(() => ({}))); + if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400); + if (!body.success) return c.json(error('invalid_action', 'Action must be accept or decline'), 400); + const response = await result(() => repository.respond(c.get('identity').id, dareId.data, body.data.action)); + return response.ok + ? c.json({ dare: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + for (const [path, method] of [ + ['submit', 'submit'], + ['complete', 'complete'], + ['cancel', 'cancel'], + ] as const) { + app.post(`/v1/dares/:dareId/${path}`, async (c) => { + const dareId = id.safeParse(c.req.param('dareId')); + if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400); + const response = await result(() => repository[method](c.get('identity').id, dareId.data)); + return response.ok + ? c.json({ dare: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + } +} diff --git a/apps/api/src/domains/dares/schema.ts b/apps/api/src/domains/dares/schema.ts new file mode 100644 index 0000000..1727489 --- /dev/null +++ b/apps/api/src/domains/dares/schema.ts @@ -0,0 +1,79 @@ +import { boolean, check, index, integer, numeric, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { profiles } from '../../db/schema.js'; + +// `couples`, `categories`, and the review-status enum are owned by other verticals. +// Their FKs stay in SQL so this fragment does not import across domain boundaries. +export const darePacks = pgTable('dare_packs', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + description: text('description'), + icon: text('icon'), + isPremium: boolean('is_premium').notNull().default(false), + isPublic: boolean('is_public').notNull().default(true), + isExplicit: boolean('is_explicit').notNull().default(false), + sortOrder: integer('sort_order').notNull().default(0), + categoryId: uuid('category_id'), + minIntensity: integer('min_intensity'), + maxIntensity: integer('max_intensity'), + avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2 }), + contentStatus: text('content_status').notNull().default('unreviewed'), + contentReviewReason: text('content_review_reason'), + contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), + contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [index('dare_packs_content_status_idx').on(table.contentStatus, table.isPublic, table.sortOrder)]); + +export const dares = pgTable('dares', { + id: uuid('id').primaryKey().defaultRandom(), + packId: uuid('pack_id').notNull().references(() => darePacks.id, { onDelete: 'cascade' }), + text: text('text').notNull(), + intensity: integer('intensity').notNull().default(1), + suggestedDurationHours: integer('suggested_duration_hours'), + contentStatus: text('content_status').notNull().default('unreviewed'), + contentReviewReason: text('content_review_reason'), + contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), + contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + index('dares_pack_idx').on(table.packId), + index('dares_content_status_idx').on(table.packId, table.contentStatus, table.intensity), +]); + +// `dare_text_snapshot` deliberately duplicates the catalogue text: a couple's +// history must survive the dare being edited, archived, or deleted. +export const sentDares = pgTable('sent_dares', { + id: uuid('id').primaryKey().defaultRandom(), + coupleId: uuid('couple_id').notNull(), + dareId: uuid('dare_id').references(() => dares.id, { onDelete: 'set null' }), + customDareText: text('custom_dare_text'), + customDareIntensity: integer('custom_dare_intensity'), + dareTextSnapshot: text('dare_text_snapshot').notNull(), + dareIntensitySnapshot: integer('dare_intensity_snapshot').notNull(), + senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }), + recipientId: uuid('recipient_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }), + status: text('status').notNull().default('pending'), + senderNotes: text('sender_notes'), + sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(), + acceptedAt: timestamp('accepted_at', { withTimezone: true }), + submittedAt: timestamp('submitted_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + index('sent_dares_couple_idx').on(table.coupleId), + index('sent_dares_recipient_status_idx').on(table.recipientId, table.status), + index('sent_dares_sender_sent_at_idx').on(table.senderId, table.sentAt), + check('sent_dares_participants_distinct_check', sql`${table.senderId} <> ${table.recipientId}`), + check('sent_dares_status_check', sql`${table.status} in ('pending','active','submitted','completed','expired','declined','cancelled')`), +]); + +export const dareMessages = pgTable('dare_messages', { + id: uuid('id').primaryKey().defaultRandom(), + sentDareId: uuid('sent_dare_id').notNull().references(() => sentDares.id, { onDelete: 'cascade' }), + senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }), + content: text('content').notNull(), + readAt: timestamp('read_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [index('dare_messages_sent_dare_created_idx').on(table.sentDareId, table.createdAt)]); diff --git a/apps/api/src/domains/operations/repository.ts b/apps/api/src/domains/operations/repository.ts index 80a77db..61baa58 100644 --- a/apps/api/src/domains/operations/repository.ts +++ b/apps/api/src/domains/operations/repository.ts @@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository { async produce(now: Date, limit=100): Promise { return transaction(this.pool, async (client) => { const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked"); - const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0}; + const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0}; if (!lock.rows[0]?.locked) return empty; const released=await client.query<{id:string;name:string}>( @@ -138,11 +138,21 @@ export class PostgresOperationsRepository implements OperationsRepository { from eligible e on conflict(dedupe_key) do nothing returning recipient_id ) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]); + // Expiring pending dares too: the original design only swept `active`, so an + // invitation the recipient never opened stayed open forever. + const expiredDares=await client.query( + `with due as ( + select id from sent_dares + where expires_at is not null and expires_at<=$1 + and status in ('pending','active','submitted') + order by expires_at for update skip locked limit $2 + ) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]); + const catchup=await this.produceCatchups(client,now,limit); const streakReminders=await this.produceStreakReminders(client,now); return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0, packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0, - catchupReminders:catchup,streakReminders}; + catchupReminders:catchup,streakReminders,daresExpired:expiredDares.rowCount??0}; }); } diff --git a/apps/api/src/domains/operations/types.ts b/apps/api/src/domains/operations/types.ts index 2ac6805..b889e14 100644 --- a/apps/api/src/domains/operations/types.ts +++ b/apps/api/src/domains/operations/types.ts @@ -49,4 +49,5 @@ export interface ProducerSummary { unpairedReminders: number; catchupReminders: number; streakReminders: number; + daresExpired: number; } diff --git a/apps/api/src/domains/profile-settings/repository.ts b/apps/api/src/domains/profile-settings/repository.ts index 91ccf99..81dd59e 100644 --- a/apps/api/src/domains/profile-settings/repository.ts +++ b/apps/api/src/domains/profile-settings/repository.ts @@ -20,6 +20,7 @@ interface PreferencesRow extends QueryResultRow { weekly_summary_enabled: boolean; unpaired_reminders_enabled: boolean; catchup_reminders_enabled: boolean; + dares_enabled: boolean; created_at: Date; updated_at: Date; } @@ -67,6 +68,7 @@ function toPreferences(row: PreferencesRow): NotificationPreferences { weekly_summary_enabled: row.weekly_summary_enabled, unpaired_reminders_enabled: row.unpaired_reminders_enabled, catchup_reminders_enabled: row.catchup_reminders_enabled, + dares_enabled: row.dares_enabled, created_at: row.created_at.toISOString(), updated_at: row.updated_at.toISOString(), }; diff --git a/apps/api/src/domains/profile-settings/types.ts b/apps/api/src/domains/profile-settings/types.ts index 7fad4ca..cb653a1 100644 --- a/apps/api/src/domains/profile-settings/types.ts +++ b/apps/api/src/domains/profile-settings/types.ts @@ -10,6 +10,7 @@ export const notificationPreferenceKeys = [ 'weekly_summary_enabled', 'unpaired_reminders_enabled', 'catchup_reminders_enabled', + 'dares_enabled', ] as const; export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2918a92..d85eb2f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -17,6 +17,7 @@ import { } from './domains/account-operations/index.js'; import { PostgresAnswersRepository } from './domains/answers/repository.js'; import { PostgresChatRepository } from './domains/chat/repository.js'; +import { PostgresDaresRepository } from './domains/dares/repository.js'; import { PostgresCoupleRepository } from './domains/couples/repository.js'; import { CoupleService } from './domains/couples/service.js'; import { PostgresPacksRepository } from './domains/packs/repository.js'; @@ -30,6 +31,7 @@ const coupleRepository = new PostgresCoupleRepository(config.databaseUrl); const packsRepository = new PostgresPacksRepository(config.databaseUrl); const answersRepository = new PostgresAnswersRepository(config.databaseUrl); const chatRepository = new PostgresChatRepository(config.databaseUrl); +const daresRepository = new PostgresDaresRepository(config.databaseUrl); const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl); const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl); const accountOperationsService = new AccountOperationsService( @@ -59,6 +61,7 @@ const app = createApp({ packsRepository, answersRepository, chatRepository, + daresRepository, profileSettingsRepository, accountOperationsService, mediaRepository, @@ -97,6 +100,7 @@ async function shutdown(signal: string): Promise { packsRepository.close(), answersRepository.close(), chatRepository.close(), + daresRepository.close(), profileSettingsRepository.close(), accountOperationsRepository.close(), mediaRepository.close(), diff --git a/apps/api/test/answers.integration.test.ts b/apps/api/test/answers.integration.test.ts index d5e3cc6..9530f29 100644 --- a/apps/api/test/answers.integration.test.ts +++ b/apps/api/test/answers.integration.test.ts @@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/repository.js' const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost'); describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository; const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc'; -beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0013_daily_limit_local_reset.sql','0014_daily_limit_response_index.sql','0015_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); +beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);}); it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});}); it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);}); diff --git a/apps/api/test/api.integration.test.ts b/apps/api/test/api.integration.test.ts index fff8365..78e3e7c 100644 --- a/apps/api/test/api.integration.test.ts +++ b/apps/api/test/api.integration.test.ts @@ -28,7 +28,7 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration', isolatedUrl.searchParams.set('options', `-c search_path=${schema}`); pool = new Pool({ connectionString: isolatedUrl.toString() }); repository = new PostgresRepository(isolatedUrl.toString()); - const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0013_daily_limit_local_reset.sql'] + const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0014_daily_limit_local_reset.sql'] .map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n'); for (const statement of migration.split('--> statement-breakpoint')) { if (statement.trim()) await pool.query(statement); diff --git a/apps/api/test/dares.integration.test.ts b/apps/api/test/dares.integration.test.ts new file mode 100644 index 0000000..01656a1 --- /dev/null +++ b/apps/api/test/dares.integration.test.ts @@ -0,0 +1,206 @@ +import { randomUUID } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js'; + +const url = process.env.DATABASE_URL; +const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false; +if (url && !local) throw new Error('Dare integration tests only permit localhost'); + +describe.skipIf(!url || !local)('dares + PostgreSQL', () => { + const admin = new Pool({ connectionString: url }); + const schema = `dares_${randomUUID().replaceAll('-', '')}`; + let pool: Pool; + let repo: PostgresDaresRepository; + + const sender = '11111111-1111-4111-8111-111111111111'; + const recipient = '22222222-2222-4222-8222-222222222222'; + const outsider = '33333333-3333-4333-8333-333333333333'; + const couple = '44444444-4444-4444-8444-444444444444'; + const otherCouple = '55555555-5555-4555-8555-555555555555'; + const freePack = '66666666-6666-4666-8666-666666666666'; + const premiumPack = '77777777-7777-4777-8777-777777777777'; + const unreviewedPack = '88888888-8888-4888-8888-888888888888'; + const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const unreviewedDare = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + + beforeAll(async () => { + await admin.query(`create schema "${schema}"`); + const isolated = new URL(url!); + isolated.searchParams.set('options', `-c search_path=${schema}`); + pool = new Pool({ connectionString: isolated.toString() }); + repo = new PostgresDaresRepository(isolated.toString()); + + const dir = new URL('../drizzle/', import.meta.url); + const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort(); + for (const name of files) { + const sql = await readFile(new URL(name, dir), 'utf8'); + for (const statement of sql.split('--> statement-breakpoint')) { + if (statement.trim()) await pool.query(statement); + } + } + + await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']); + await pool.query( + 'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)', + [sender, recipient, outsider, couple, otherCouple], + ); + await pool.query( + `insert into dare_packs(id,name,is_premium,content_status) values + ($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`, + [freePack, premiumPack, unreviewedPack], + ); + await pool.query( + `insert into dares(id,pack_id,text,intensity,content_status) values + ($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`, + [freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack], + ); + }); + + beforeEach(async () => { + await pool.query('delete from sent_dares'); + await pool.query('delete from operations_outbox'); + await pool.query('update profiles set is_premium=false'); + }); + + afterAll(async () => { + await repo.close(); + await pool.end(); + await admin.query(`drop schema "${schema}" cascade`); + await admin.end(); + }); + + it('hides unreviewed packs and dares from the catalogue', async () => { + const catalog = await repo.getCatalog(sender); + const ids = catalog.packs.map((pack) => pack.id); + expect(ids).toContain(freePack); + expect(ids).toContain(premiumPack); + expect(ids).not.toContain(unreviewedPack); + await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' }); + await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' }); + }); + + it('gates premium packs and custom dares, and unlocks when either partner pays', async () => { + await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' }); + await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' }); + await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' }); + + // Premium is couple-shared: the partner paying unlocks the sender. + await pool.query('update profiles set is_premium=true where id=$1', [recipient]); + expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null }); + await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1); + const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 }); + expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 }); + }); + + it('enforces the free weekly send allowance without blocking the response side', async () => { + for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) { + await repo.send(sender, { dare_id: freeDare }); + } + expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0); + await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' }); + + // The quota is per-sender, so the recipient can still answer back. + const reply = await repo.send(recipient, { dare_id: freeDare }); + expect(reply.direction).toBe('outgoing'); + }); + + it('serializes concurrent sends so the free allowance cannot be raced', async () => { + const attempts = await Promise.allSettled( + Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })), + ); + expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT); + const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]); + expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT)); + }); + + it('walks the full loop and records each transition exactly once', async () => { + const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' }); + expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' }); + expect(dare.expires_at).not.toBeNull(); + + expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active'); + expect((await repo.submit(recipient, dare.id)).status).toBe('submitted'); + expect((await repo.complete(sender, dare.id)).status).toBe('completed'); + + const events = await pool.query<{ dedupe_key: string; recipient_id: string }>( + "select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at", + ); + expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']); + expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]); + }); + + it('never puts dare text in a notification payload', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await repo.respond(recipient, dare.id, 'accept'); + const leaked = await pool.query<{ count: string }>( + "select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'", + ); + expect(leaked.rows[0]!.count).toBe('0'); + }); + + it('respects the dares notification preference', async () => { + await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]); + const dare = await repo.send(sender, { dare_id: freeDare }); + const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]); + expect(events.rowCount).toBe(0); + // The sender has not opted out, so their side still fires. + await repo.respond(recipient, dare.id, 'decline'); + const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]); + expect(senderEvents.rowCount).toBe(1); + await pool.query('delete from notification_preferences where user_id=$1', [recipient]); + }); + + it('enforces actor and state on every transition', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' }); + await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' }); + await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' }); + await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' }); + + await repo.respond(recipient, dare.id, 'accept'); + await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' }); + expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled'); + await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' }); + }); + + it('keeps history intact when the source dare is deleted', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await pool.query('delete from dares where id=$1', [freeDare]); + const history = await repo.listDares(sender, 'active'); + expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 }); + await pool.query( + "insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')", + [freeDare, freePack], + ); + }); + + it('scopes listing and stats to the couple', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await repo.respond(recipient, dare.id, 'accept'); + await repo.submit(recipient, dare.id); + await repo.complete(sender, dare.id); + + expect(await repo.listDares(outsider, 'active')).toEqual([]); + expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 }); + expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 }); + expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 }); + + const active = await repo.listDares(sender, 'active'); + const history = await repo.listDares(sender, 'history'); + expect(active).toEqual([]); + expect(history).toHaveLength(1); + expect(history[0]!.direction).toBe('outgoing'); + expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming'); + }); + + it('rejects durations outside the offered presets', async () => { + await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' }); + }); + + it('refuses to send without a partner', async () => { + await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' }); + }); +}); diff --git a/apps/api/test/dares.routes.test.ts b/apps/api/test/dares.routes.test.ts new file mode 100644 index 0000000..cc2cb34 --- /dev/null +++ b/apps/api/test/dares.routes.test.ts @@ -0,0 +1,117 @@ +import { Hono } from 'hono'; +import { describe, expect, it, vi } from 'vitest'; +import type { AuthIdentity } from '../src/auth.js'; +import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js'; +import { registerDareRoutes } from '../src/domains/dares/routes.js'; + +const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null }; +const partner = '22222222-2222-4222-8222-222222222222'; +const dareId = '33333333-3333-4333-8333-333333333333'; +const packId = '44444444-4444-4444-8444-444444444444'; +const catalogueDareId = '55555555-5555-4555-8555-555555555555'; + +const dare: SentDare = { + id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId, + text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner, + direction: 'outgoing', status: 'pending', sender_notes: null, + sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null, +}; + +function setup(overrides: Partial = {}) { + const repository: DaresRepository = { + getCatalog: vi.fn(async () => ({ + entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 }, + packs: [], + })), + listPackDares: vi.fn(async () => []), + listDares: vi.fn(async () => [dare]), + send: vi.fn(async () => dare), + respond: vi.fn(async () => ({ ...dare, status: 'active' as const })), + submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })), + complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })), + cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })), + stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })), + close: vi.fn(async () => undefined), + ...overrides, + }; + const app = new Hono<{ Variables: { identity: AuthIdentity } }>(); + app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); }); + registerDareRoutes(app, repository); + return { app, repository }; +} + +function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +describe('dare routes', () => { + it('derives the actor from the bearer identity, never the body', async () => { + const { app, repository } = setup(); + const response = await post(app, '/v1/dares', { + dare_id: catalogueDareId, + sender_notes: ' be brave ', + }); + expect(response.status).toBe(201); + expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId })); + }); + + it('rejects client-supplied ownership fields', async () => { + const { app } = setup(); + const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner }); + expect(response.status).toBe(400); + }); + + it('requires exactly one of dare_id or custom text', async () => { + const { app } = setup(); + expect((await post(app, '/v1/dares', {})).status).toBe(400); + expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400); + expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201); + }); + + it('accepts only preset durations', async () => { + const { app } = setup(); + expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201); + expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400); + }); + + it('validates identifiers and filters', async () => { + const { app } = setup(); + expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400); + expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400); + expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400); + expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400); + }); + + it('routes each transition to its repository method', async () => { + const { app, repository } = setup(); + await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' }); + expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline'); + await post(app, `/v1/dares/${dareId}/submit`); + expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId); + await post(app, `/v1/dares/${dareId}/complete`); + expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId); + await post(app, `/v1/dares/${dareId}/cancel`); + expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId); + }); + + it('surfaces domain errors with their status codes', async () => { + const { app } = setup({ + send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }), + submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }), + }); + const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' }); + expect(paywalled.status).toBe(402); + expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } }); + expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409); + }); + + it('exposes the duration presets the send sheet offers', async () => { + const { app } = setup(); + const response = await app.request('/v1/dares/durations'); + expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] }); + }); +}); diff --git a/apps/api/test/operations.integration.test.ts b/apps/api/test/operations.integration.test.ts index 8fcc168..820297c 100644 --- a/apps/api/test/operations.integration.test.ts +++ b/apps/api/test/operations.integration.test.ts @@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{ const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository; beforeAll(async()=>{ await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString()); - for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0013_daily_limit_local_reset.sql','0015_couple_streak_locality.sql','0016_streak_reminder_preference.sql']){ + for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){ const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement); } await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']); diff --git a/apps/api/test/operations.runner.test.ts b/apps/api/test/operations.runner.test.ts index bc31a70..c06900d 100644 --- a/apps/api/test/operations.runner.test.ts +++ b/apps/api/test/operations.runner.test.ts @@ -5,7 +5,7 @@ import { OperationsRunner } from '../src/domains/operations/runner.js'; import type { OperationItem } from '../src/domains/operations/types.js'; import { loadWorkerConfig } from '../src/workers/config.js'; -const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0}; +const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0}; function repository(items:OperationItem[]):OperationsRepository { return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined), message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)}; diff --git a/apps/api/test/profile-settings.routes.test.ts b/apps/api/test/profile-settings.routes.test.ts index 4f740b3..5b4b337 100644 --- a/apps/api/test/profile-settings.routes.test.ts +++ b/apps/api/test/profile-settings.routes.test.ts @@ -24,6 +24,7 @@ const preferences = { weekly_summary_enabled: true, unpaired_reminders_enabled: true, catchup_reminders_enabled: true, + dares_enabled: true, created_at: '2026-08-27T10:00:00.000Z', updated_at: '2026-08-27T10:00:00.000Z', }; diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 9abf19e..3d9126a 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -92,7 +92,7 @@ android { applicationId 'com.sauci.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 46 + versionCode 47 versionName "1.0.5" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index ba28558..fc29235 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -183,7 +183,7 @@ "backgroundColor": "#1a1a2e" }, "package": "com.sauci.app", - "versionCode": 46, + "versionCode": 47, "googleServicesFile": "./google-services.json", "permissions": [ "RECEIVE_BOOT_COMPLETED", diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index a5a6e79..980af67 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -65,7 +65,7 @@ export default function AppLayout() { const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore(); const { fetchStreak } = useStreakStore(); const messageToastAnim = useRef(new Animated.Value(-100)).current; - const messageTimeoutRef = useRef(null); + const messageTimeoutRef = useRef | null>(null); const [isLocked, setIsLocked] = useState(false); const hasCheckedInitialBiometric = useRef(false); const wentToBackgroundAt = useRef(null); diff --git a/apps/mobile/app/(app)/dares.tsx b/apps/mobile/app/(app)/dares.tsx index f182163..e1106c2 100644 --- a/apps/mobile/app/(app)/dares.tsx +++ b/apps/mobile/app/(app)/dares.tsx @@ -1,232 +1,5 @@ -import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native"; -import { Ionicons } from "@expo/vector-icons"; -import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; -import { GradientBackground } from "../../src/components/ui"; -import { colors, spacing, radius, typography, animations } from "../../src/theme"; -import { useFeatureInterest } from "../../src/hooks/useFeatureInterest"; - -const ACCENT_COLOR = colors.premium.gold; - -const AnimatedPressable = Animated.createAnimatedComponent(Pressable); - -export default function DaresScreen() { - const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } = - useFeatureInterest("dares"); - - const scale = useSharedValue(1); - const animatedButtonStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - const handlePressIn = () => { - scale.value = withSpring(0.96, animations.spring); - }; - - const handlePressOut = () => { - scale.value = withSpring(1, animations.spring); - }; - - return ( - - - - {/* Title section */} - ADVENTURE - Dares - - {/* Decorative separator */} - - - - - - - {/* Coming soon badge */} - - ARRIVING SOON - - - {/* Description */} - - Try playful challenges together and create unforgettable moments. - - - {/* Feature hints */} - - - - Provocative challenges - - - - Multiple intensity levels - - - - Designed for two - - - - {/* Bottom teaser */} - Are you ready to play? - - {/* Interest button - always reserve space to prevent layout shift */} - - {isAuthenticated && !isLoading && ( - - - {isToggling ? ( - - ) : ( - <> - - - {isInterested ? "You're on the list!" : "I'm interested"} - - - )} - - - )} - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: spacing.lg, - paddingTop: 60, - paddingBottom: Platform.OS === 'ios' ? 120 : 100, - }, - content: { - width: '100%', - alignItems: 'center', - paddingHorizontal: spacing.md, - }, - label: { - ...typography.caption1, - fontWeight: '600', - letterSpacing: 3, - color: ACCENT_COLOR, - textAlign: 'center', - marginBottom: spacing.xs, - }, - title: { - ...typography.largeTitle, - color: colors.text, - textAlign: 'center', - }, - separator: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginVertical: spacing.lg, - width: 140, - }, - separatorLine: { - flex: 1, - height: 1, - backgroundColor: 'rgba(212, 175, 55, 0.3)', - }, - separatorDiamond: { - width: 6, - height: 6, - backgroundColor: ACCENT_COLOR, - transform: [{ rotate: '45deg' }], - marginHorizontal: spacing.md, - opacity: 0.6, - }, - badge: { - backgroundColor: colors.backgroundLight, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: colors.border, - marginBottom: spacing.xl, - }, - interestButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.backgroundLight, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm + 2, - borderRadius: radius.full, - borderWidth: 1, - borderColor: colors.border, - gap: spacing.sm, - }, - interestButtonActive: { - backgroundColor: colors.background, - borderColor: colors.primary, - }, - interestButtonText: { - ...typography.subhead, - fontWeight: '600', - color: ACCENT_COLOR, - }, - badgeText: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 2, - color: ACCENT_COLOR, - }, - description: { - ...typography.body, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.xl, - paddingHorizontal: spacing.md, - }, - features: { - marginBottom: spacing.xl, - }, - featureItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.sm, - }, - featureText: { - ...typography.subhead, - color: colors.text, - marginLeft: spacing.sm, - }, - teaser: { - ...typography.footnote, - fontStyle: 'italic', - color: colors.textTertiary, - textAlign: 'center', - }, - interestButtonContainer: { - marginTop: spacing.xl, - minHeight: 44, - }, -}); +import { DaresScreen } from "../../src/features/dares"; + +export default function DaresRoute() { + return ; +} diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index 85aa3ec..ccdc392 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -7,7 +7,7 @@ import { BlurView } from 'expo-blur'; import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store'; import { GradientBackground } from '../../src/components/ui'; import { Paywall } from '../../src/components/paywall'; -import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery'; +import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery'; import { StreakDisplay } from '../../src/components/StreakDisplay'; import { colors, spacing, typography, radius } from '../../src/theme'; import type { QuestionPack, Category } from '../../src/types'; @@ -125,6 +125,9 @@ export default function DiscoveryScreen() { {/* Live Draw Activity Tile */} + {/* Dares */} + + {/* Content Rows by Category */} {packsByCategory.map((group, index) => ( { + if (path.startsWith("/v1/dares?filter=active")) return { dares: [incoming] } as never; + if (path.startsWith("/v1/dares?filter=history")) return { dares: [] } as never; + if (path === "/v1/dares/packs") return catalog as never; + if (path === "/v1/dares/stats") return stats as never; + throw new Error(`unexpected path ${path}`); + }); +} + +describe("useDares", () => { + beforeEach(() => jest.restoreAllMocks()); + + it("loads the inbox, catalogue and stats together", async () => { + mockLoad(); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.active).toHaveLength(1); + expect(result.current.history).toHaveLength(0); + expect(result.current.catalog?.entitlement.sends_remaining).toBe(2); + expect(result.current.stats?.active).toBe(1); + expect(result.current.error).toBeNull(); + }); + + it("posts a response and refreshes", async () => { + mockLoad(); + const postSpy = jest.spyOn(apiClient, "post").mockResolvedValue({ dare: { ...incoming, status: "active" } } as never); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.respond("dare-1", "accept"); + }); + expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" }); + }); + + it("routes a premium refusal to the paywall rather than the error banner", async () => { + mockLoad(); + jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402)); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.send({ custom_dare_text: "mine" }); + }); + expect(result.current.paywallReason).toBe("Custom dares require premium"); + expect(result.current.error).toBeNull(); + + act(() => result.current.clearPaywall()); + expect(result.current.paywallReason).toBeNull(); + }); + + it("surfaces non-premium failures inline", async () => { + mockLoad(); + jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("A pending dare cannot change to submitted", 409)); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.submit("dare-1"); + }); + expect(result.current.error).toBe("A pending dare cannot change to submitted"); + expect(result.current.paywallReason).toBeNull(); + }); + + it("reports a load failure without crashing", async () => { + jest.spyOn(apiClient, "get").mockRejectedValue(new ApiError("offline", 500)); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe("offline"); + }); +}); diff --git a/apps/mobile/src/components/discovery/DaresTile.tsx b/apps/mobile/src/components/discovery/DaresTile.tsx new file mode 100644 index 0000000..d4709ab --- /dev/null +++ b/apps/mobile/src/components/discovery/DaresTile.tsx @@ -0,0 +1,123 @@ +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import * as Haptics from 'expo-haptics'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { colors, spacing, radius, typography, shadows, tileColors } from '../../theme'; +import { useAuthStore } from '../../store'; +import { useIncomingDareCount } from '../../features/dares/hooks/useIncomingDareCount'; + +const HORIZONTAL_PADDING = spacing.lg; +const TILE_HEIGHT = 100; + +interface DaresTileProps { + delay?: number; +} + +export function DaresTile({ delay = 0 }: DaresTileProps) { + const { user } = useAuthStore(); + const paired = Boolean(user?.couple_id); + const pending = useIncomingDareCount(paired); + + if (!paired) return null; + + const handlePress = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + router.push('/(app)/dares' as never); + }; + + return ( + + + + + + + + + Dares + + {pending > 0 + ? `${pending} dare${pending === 1 ? '' : 's'} waiting for you` + : 'Challenge your partner to something playful'} + + + + {pending > 0 ? ( + + {pending} + + ) : ( + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginBottom: spacing.lg, + }, + tileWrapper: { + paddingHorizontal: HORIZONTAL_PADDING, + }, + tile: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: tileColors.amber, + borderRadius: radius.lg, + padding: spacing.md, + height: TILE_HEIGHT, + overflow: 'hidden', + position: 'relative', + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + content: { + flex: 1, + }, + title: { + ...typography.headline, + color: colors.text, + fontWeight: '700', + }, + subtitle: { + ...typography.caption1, + color: 'rgba(255, 255, 255, 0.8)', + marginTop: 2, + }, + badge: { + minWidth: 24, + height: 24, + paddingHorizontal: 6, + borderRadius: radius.full, + backgroundColor: colors.background, + alignItems: 'center', + justifyContent: 'center', + }, + badgeText: { + ...typography.caption1, + color: colors.text, + fontWeight: '700', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.05)', + borderRadius: radius.lg, + pointerEvents: 'none', + }, +}); diff --git a/apps/mobile/src/components/discovery/index.ts b/apps/mobile/src/components/discovery/index.ts index 680efc9..e1c5f2e 100644 --- a/apps/mobile/src/components/discovery/index.ts +++ b/apps/mobile/src/components/discovery/index.ts @@ -1,5 +1,6 @@ -export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile'; -export { ContentRow } from './ContentRow'; -export { CompactHeader } from './CompactHeader'; -export { CircularProgress } from './CircularProgress'; -export { LiveDrawTile } from './LiveDrawTile'; +export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile'; +export { ContentRow } from './ContentRow'; +export { CompactHeader } from './CompactHeader'; +export { CircularProgress } from './CircularProgress'; +export { LiveDrawTile } from './LiveDrawTile'; +export { DaresTile } from './DaresTile'; diff --git a/apps/mobile/src/features/dares/DaresScreen.tsx b/apps/mobile/src/features/dares/DaresScreen.tsx new file mode 100644 index 0000000..16f1606 --- /dev/null +++ b/apps/mobile/src/features/dares/DaresScreen.tsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import Animated, { FadeIn } from "react-native-reanimated"; +import { GradientBackground } from "../../components/ui"; +import { Paywall } from "../../components/paywall"; +import { colors, featureColors, radius, spacing, typography } from "../../theme"; +import { DareCard, DarePackGrid, SendDareSheet } from "./components"; +import { useDares } from "./hooks/useDares"; +import type { DarePack } from "./types"; + +const ACCENT = featureColors.dares.accent; + +type Tab = "active" | "send" | "history"; + +const TABS: { key: Tab; label: string }[] = [ + { key: "active", label: "Active" }, + { key: "send", label: "Send" }, + { key: "history", label: "History" }, +]; + +export function DaresScreen() { + const dares = useDares(); + const [tab, setTab] = useState("active"); + const [sheetPack, setSheetPack] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [paywallOpen, setPaywallOpen] = useState(false); + + const openPaywall = useCallback(() => { + setSheetOpen(false); + setPaywallOpen(true); + }, []); + + const closePaywall = useCallback(() => { + setPaywallOpen(false); + dares.clearPaywall(); + }, [dares]); + + const entitlement = dares.catalog?.entitlement ?? null; + const premium = entitlement?.is_premium ?? false; + + const handleSelectPack = useCallback( + (pack: DarePack) => { + if (pack.is_premium && !premium) { + openPaywall(); + return; + } + setSheetPack(pack); + setSheetOpen(true); + }, + [premium, openPaywall], + ); + + const handleWriteCustom = useCallback(() => { + if (!premium) { + openPaywall(); + return; + } + setSheetPack(null); + setSheetOpen(true); + }, [premium, openPaywall]); + + const handleAction = useCallback( + (dareId: string, action: "accept" | "decline" | "submit" | "complete" | "cancel") => { + if (action === "accept" || action === "decline") { + void dares.respond(dareId, action); + return; + } + void dares[action](dareId); + }, + [dares], + ); + + // A premium refusal from the API opens the paywall even when the client thought + // the action was allowed (stale entitlement, or a plan that lapsed mid-session). + const { paywallReason } = dares; + useEffect(() => { + if (paywallReason) setPaywallOpen(true); + }, [paywallReason]); + + const list = tab === "history" ? dares.history : dares.active; + + return ( + + void dares.refresh()} tintColor={ACCENT} /> + } + > + ADVENTURE + Dares + + {dares.stats ? ( + + + + + + ) : null} + + {!premium && entitlement?.sends_remaining !== null && entitlement !== null ? ( + + + + {entitlement.sends_remaining} of {entitlement.weekly_send_limit} free dares left this week + + Upgrade + + ) : null} + + + {TABS.map(({ key, label }) => ( + setTab(key)} + style={[styles.tab, tab === key && styles.tabActive]} + > + {label} + + ))} + + + {dares.error ? {dares.error} : null} + + {dares.loading ? ( + + ) : tab === "send" ? ( + + ) : list.length === 0 ? ( + + + + {tab === "active" ? "No dares in play" : "Nothing here yet"} + + + {tab === "active" + ? "Send your partner a dare and see what happens." + : "Completed and declined dares will show up here."} + + {tab === "active" ? ( + setTab("send")}> + Browse dares + + ) : null} + + ) : ( + list.map((dare) => ( + + )) + )} + + + setSheetOpen(false)} + onSend={dares.send} + onRequestPremium={openPaywall} + /> + + { + closePaywall(); + void dares.refresh(); + }} + /> + + ); +} + +function Stat({ value, label }: { value: number; label: string }) { + return ( + + {value} + {label} + + ); +} + +const styles = StyleSheet.create({ + scroll: { flex: 1 }, + container: { paddingHorizontal: spacing.lg, paddingTop: 60, paddingBottom: 120 }, + label: { + ...typography.caption1, fontWeight: "600", letterSpacing: 3, + color: ACCENT, marginBottom: spacing.xs, + }, + title: { ...typography.largeTitle, color: colors.text, marginBottom: spacing.lg }, + statsRow: { flexDirection: "row", gap: spacing.sm, marginBottom: spacing.md }, + stat: { + flex: 1, alignItems: "center", + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, + paddingVertical: spacing.md, + }, + statValue: { ...typography.title2, color: ACCENT }, + statLabel: { ...typography.caption2, color: colors.textSecondary, marginTop: 2 }, + quotaBanner: { + flexDirection: "row", alignItems: "center", gap: spacing.sm, + backgroundColor: "rgba(212,175,55,0.08)", + borderRadius: radius.md, borderWidth: 1, borderColor: "rgba(212,175,55,0.3)", + paddingHorizontal: spacing.md, paddingVertical: spacing.sm, + marginBottom: spacing.md, + }, + quotaText: { ...typography.caption1, color: colors.textSecondary, flex: 1 }, + quotaCta: { ...typography.caption1, color: ACCENT, fontWeight: "700" }, + tabs: { + flexDirection: "row", + backgroundColor: colors.backgroundLight, + borderRadius: radius.full, + padding: 3, + marginBottom: spacing.lg, + }, + tab: { flex: 1, alignItems: "center", paddingVertical: spacing.sm, borderRadius: radius.full }, + tabActive: { backgroundColor: ACCENT }, + tabText: { ...typography.subhead, color: colors.textSecondary, fontWeight: "600" }, + tabTextActive: { color: colors.background }, + loader: { marginTop: spacing.xl }, + error: { ...typography.footnote, color: colors.error, marginBottom: spacing.md }, + empty: { alignItems: "center", paddingVertical: spacing.xxl, gap: spacing.sm }, + emptyTitle: { ...typography.headline, color: colors.text, marginTop: spacing.sm }, + emptyBody: { + ...typography.subhead, color: colors.textSecondary, + textAlign: "center", paddingHorizontal: spacing.lg, + }, + emptyButton: { + marginTop: spacing.md, + paddingHorizontal: spacing.lg, paddingVertical: spacing.sm + 2, + borderRadius: radius.full, borderWidth: 1, borderColor: ACCENT, + }, + emptyButtonText: { ...typography.subhead, color: ACCENT, fontWeight: "600" }, +}); + +export default DaresScreen; diff --git a/apps/mobile/src/features/dares/components/DareCard.tsx b/apps/mobile/src/features/dares/components/DareCard.tsx new file mode 100644 index 0000000..6ef0f57 --- /dev/null +++ b/apps/mobile/src/features/dares/components/DareCard.tsx @@ -0,0 +1,185 @@ +import { memo } from "react"; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { colors, featureColors, radius, spacing, typography } from "../../../theme"; +import type { SentDare } from "../types"; + +const ACCENT = featureColors.dares.accent; + +/** The one action each side owns at each step of the loop. */ +type Action = "accept" | "decline" | "submit" | "complete" | "cancel"; + +export interface DareCardProps { + dare: SentDare; + busy: boolean; + onAction: (dareId: string, action: Action) => void; +} + +function relativeDeadline(expiresAt: string | null): string | null { + if (!expiresAt) return null; + const remainingMs = new Date(expiresAt).getTime() - Date.now(); + if (remainingMs <= 0) return "Time's up"; + const hours = Math.floor(remainingMs / 3_600_000); + if (hours >= 48) return `${Math.floor(hours / 24)} days left`; + if (hours >= 1) return `${hours}h left`; + return `${Math.max(1, Math.round(remainingMs / 60_000))}m left`; +} + +function statusLabel(dare: SentDare): string { + const them = dare.direction === "incoming" ? "They dared you" : "You dared them"; + switch (dare.status) { + case "pending": return dare.direction === "incoming" ? "Waiting on you" : "Waiting on them"; + case "active": return dare.direction === "incoming" ? "Your turn" : "They accepted"; + case "submitted": return dare.direction === "incoming" ? "Waiting to be confirmed" : "They say it's done"; + case "completed": return "Completed"; + case "declined": return "Declined"; + case "cancelled": return "Cancelled"; + case "expired": return "Expired"; + default: return them; + } +} + +/** Only surfaces buttons the viewer is actually allowed to press. */ +function actionsFor(dare: SentDare): { action: Action; label: string; primary?: boolean }[] { + if (dare.direction === "incoming") { + if (dare.status === "pending") { + return [ + { action: "accept", label: "Accept", primary: true }, + { action: "decline", label: "Pass" }, + ]; + } + if (dare.status === "active") return [{ action: "submit", label: "I did it", primary: true }]; + return []; + } + if (dare.status === "submitted") { + return [ + { action: "complete", label: "Confirm", primary: true }, + { action: "cancel", label: "Cancel" }, + ]; + } + if (dare.status === "pending" || dare.status === "active") { + return [{ action: "cancel", label: "Cancel" }]; + } + return []; +} + +export const DareCard = memo(function DareCard({ dare, busy, onAction }: DareCardProps) { + const deadline = relativeDeadline(dare.expires_at); + const actions = actionsFor(dare); + + return ( + + + + + {statusLabel(dare)} + + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + + + {dare.text} + + {dare.sender_notes ? “{dare.sender_notes}” : null} + + {deadline ? ( + + + {deadline} + + ) : null} + + {actions.length > 0 ? ( + + {busy ? ( + + ) : ( + actions.map(({ action, label, primary }) => ( + onAction(dare.id, action)} + style={[styles.button, primary ? styles.buttonPrimary : styles.buttonSecondary]} + > + {label} + + )) + )} + + ) : null} + + ); +}); + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.backgroundLight, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + marginBottom: spacing.md, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.sm, + }, + directionRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs }, + status: { + ...typography.caption1, + color: ACCENT, + fontWeight: "600", + letterSpacing: 0.5, + }, + intensityRow: { flexDirection: "row", gap: 3 }, + pip: { + width: 5, + height: 5, + borderRadius: radius.full, + backgroundColor: colors.border, + }, + pipFilled: { backgroundColor: ACCENT }, + text: { ...typography.body, color: colors.text }, + notes: { + ...typography.footnote, + color: colors.textSecondary, + fontStyle: "italic", + marginTop: spacing.sm, + }, + deadlineRow: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + marginTop: spacing.sm, + }, + deadline: { ...typography.caption1, color: colors.textTertiary }, + actions: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + gap: spacing.sm, + marginTop: spacing.md, + minHeight: 36, + }, + button: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + }, + buttonPrimary: { backgroundColor: ACCENT, borderColor: ACCENT }, + buttonSecondary: { backgroundColor: "transparent", borderColor: colors.border }, + buttonText: { ...typography.subhead, fontWeight: "600", color: colors.textSecondary }, + buttonTextPrimary: { color: colors.background }, +}); diff --git a/apps/mobile/src/features/dares/components/DarePackGrid.tsx b/apps/mobile/src/features/dares/components/DarePackGrid.tsx new file mode 100644 index 0000000..f01e212 --- /dev/null +++ b/apps/mobile/src/features/dares/components/DarePackGrid.tsx @@ -0,0 +1,83 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { colors, featureColors, radius, spacing, typography } from "../../../theme"; +import type { DareEntitlement, DarePack } from "../types"; + +const ACCENT = featureColors.dares.accent; + +export interface DarePackGridProps { + packs: DarePack[]; + entitlement: DareEntitlement | null; + onSelectPack: (pack: DarePack) => void; + onWriteCustom: () => void; +} + +export function DarePackGrid({ packs, entitlement, onSelectPack, onWriteCustom }: DarePackGridProps) { + const premium = entitlement?.is_premium ?? false; + + return ( + + + + + + + Write your own + + {premium ? "Make it personal" : "Premium"} + + + + + + {packs.map((pack) => { + const locked = pack.is_premium && !premium; + return ( + onSelectPack(pack)}> + {pack.icon ?? "🔥"} + + + {pack.name} + {locked ? : null} + + + {pack.description ?? `${pack.dare_count} dares`} + + + + + ); + })} + + ); +} + +const row = { + flexDirection: "row" as const, + alignItems: "center" as const, + backgroundColor: colors.backgroundLight, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.md, +}; + +const styles = StyleSheet.create({ + customRow: { ...row, borderColor: "rgba(212,175,55,0.35)" }, + customIcon: { + width: 36, height: 36, borderRadius: radius.full, + alignItems: "center", justifyContent: "center", + backgroundColor: "rgba(212,175,55,0.12)", + }, + customCopy: { flex: 1 }, + customTitle: { ...typography.headline, color: colors.text }, + customSubtitle: { ...typography.caption1, color: ACCENT, marginTop: 2 }, + packRow: row, + packIcon: { fontSize: 26, width: 36, textAlign: "center" }, + packCopy: { flex: 1 }, + packTitleRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs }, + packTitle: { ...typography.headline, color: colors.text }, + packSubtitle: { ...typography.caption1, color: colors.textSecondary, marginTop: 2 }, +}); diff --git a/apps/mobile/src/features/dares/components/SendDareSheet.tsx b/apps/mobile/src/features/dares/components/SendDareSheet.tsx new file mode 100644 index 0000000..afcb49a --- /dev/null +++ b/apps/mobile/src/features/dares/components/SendDareSheet.tsx @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable, + ScrollView, StyleSheet, Text, TextInput, View, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { daresApi } from "../../../lib/daresApi"; +import { colors, featureColors, radius, spacing, typography } from "../../../theme"; +import { DURATION_OPTIONS, type DareItem, type DarePack, type SendDarePayload } from "../types"; + +const ACCENT = featureColors.dares.accent; + +export interface SendDareSheetProps { + visible: boolean; + pack: DarePack | null; + /** Null while the catalogue is loading; drives the custom-dare affordance. */ + canSendCustom: boolean; + onClose: () => void; + onSend: (payload: SendDarePayload) => Promise; + onRequestPremium: () => void; +} + +export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, onRequestPremium }: SendDareSheetProps) { + const [dares, setDares] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [customText, setCustomText] = useState(""); + const [durationHours, setDurationHours] = useState(24); + const [notes, setNotes] = useState(""); + const [sending, setSending] = useState(false); + + useEffect(() => { + if (!visible || !pack) return; + setLoading(true); + setSelectedId(null); + setCustomText(""); + setNotes(""); + daresApi + .packDares(pack.id) + .then((result) => setDares(result.dares)) + .catch(() => setDares([])) + .finally(() => setLoading(false)); + }, [visible, pack]); + + const isCustom = pack === null; + const canSubmit = isCustom ? customText.trim().length > 0 : selectedId !== null; + + const handleSend = useCallback(async () => { + if (!canSubmit || sending) return; + setSending(true); + const payload: SendDarePayload = isCustom + ? { custom_dare_text: customText.trim(), custom_dare_intensity: 3, duration_hours: durationHours } + : { dare_id: selectedId!, duration_hours: durationHours }; + if (notes.trim()) payload.sender_notes = notes.trim(); + const sent = await onSend(payload); + setSending(false); + if (sent) onClose(); + }, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]); + + return ( + + + + + + {isCustom ? "Write a dare" : pack?.name} + + + + + + + {isCustom ? ( + canSendCustom ? ( + + ) : ( + + + Custom dares are a premium feature + + ) + ) : loading ? ( + + ) : ( + dares.map((dare) => { + const selected = dare.id === selectedId; + return ( + setSelectedId(dare.id)} + style={[styles.option, selected && styles.optionSelected]} + > + + {dare.text} + + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + + ); + }) + )} + + TIME LIMIT + + {DURATION_OPTIONS.map((option) => { + const selected = option.hours === durationHours; + return ( + setDurationHours(option.hours)} + style={[styles.chip, selected && styles.chipSelected]} + > + + {option.label} + + + ); + })} + + + ADD A NOTE + + + + + {sending ? ( + + ) : ( + Send dare + )} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "flex-end" }, + sheet: { + backgroundColor: colors.background, + borderTopLeftRadius: radius.xxl, + borderTopRightRadius: radius.xxl, + paddingHorizontal: spacing.lg, + paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.lg, + maxHeight: "88%", + }, + handle: { + width: 36, height: 4, borderRadius: radius.full, + backgroundColor: colors.border, alignSelf: "center", marginTop: spacing.sm, + }, + header: { + flexDirection: "row", alignItems: "center", justifyContent: "space-between", + paddingVertical: spacing.md, + }, + title: { ...typography.title3, color: colors.text }, + body: { marginBottom: spacing.md }, + loader: { marginVertical: spacing.xl }, + option: { + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + marginBottom: spacing.sm, + }, + optionSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.08)" }, + optionText: { ...typography.subhead, color: colors.textSecondary }, + optionTextSelected: { color: colors.text }, + intensityRow: { flexDirection: "row", gap: 3, marginTop: spacing.sm }, + pip: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: colors.border }, + pipFilled: { backgroundColor: ACCENT }, + customInput: { + ...typography.body, + color: colors.text, + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + minHeight: 100, + textAlignVertical: "top", + }, + lockedCard: { + flexDirection: "row", alignItems: "center", gap: spacing.sm, + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, borderWidth: 1, borderColor: ACCENT, + padding: spacing.md, + }, + lockedText: { ...typography.subhead, color: ACCENT, fontWeight: "600" }, + sectionLabel: { + ...typography.caption2, + color: colors.textTertiary, + letterSpacing: 2, + fontWeight: "600", + marginTop: spacing.lg, + marginBottom: spacing.sm, + }, + durationRow: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }, + chip: { + paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2, + borderRadius: radius.full, borderWidth: 1, borderColor: colors.border, + }, + chipSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.12)" }, + chipText: { ...typography.footnote, color: colors.textSecondary }, + chipTextSelected: { color: ACCENT, fontWeight: "600" }, + notesInput: { + ...typography.subhead, + color: colors.text, + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + }, + sendButton: { + backgroundColor: ACCENT, + borderRadius: radius.full, + paddingVertical: spacing.md, + alignItems: "center", + justifyContent: "center", + minHeight: 50, + }, + sendButtonDisabled: { opacity: 0.4 }, + sendButtonText: { ...typography.headline, color: colors.background, fontWeight: "700" }, +}); diff --git a/apps/mobile/src/features/dares/components/index.ts b/apps/mobile/src/features/dares/components/index.ts new file mode 100644 index 0000000..206ca0b --- /dev/null +++ b/apps/mobile/src/features/dares/components/index.ts @@ -0,0 +1,6 @@ +export { DareCard } from "./DareCard"; +export type { DareCardProps } from "./DareCard"; +export { DarePackGrid } from "./DarePackGrid"; +export type { DarePackGridProps } from "./DarePackGrid"; +export { SendDareSheet } from "./SendDareSheet"; +export type { SendDareSheetProps } from "./SendDareSheet"; diff --git a/apps/mobile/src/features/dares/hooks/useDares.ts b/apps/mobile/src/features/dares/hooks/useDares.ts new file mode 100644 index 0000000..9c21627 --- /dev/null +++ b/apps/mobile/src/features/dares/hooks/useDares.ts @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { daresApi } from "../../../lib/daresApi"; +import { ApiError } from "../../../lib/apiClient"; +import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types"; + +/** Matches the chat poll cadence; the API has no realtime channel. */ +const POLL_INTERVAL_MS = 5_000; + +export interface UseDaresReturn { + active: SentDare[]; + history: SentDare[]; + catalog: DareCatalog | null; + stats: DareStats | null; + loading: boolean; + busyDareId: string | null; + error: string | null; + /** Set when an action was refused for want of premium, so the screen can open the paywall. */ + paywallReason: string | null; + clearPaywall: () => void; + refresh: () => Promise; + send: (payload: SendDarePayload) => Promise; + respond: (dareId: string, action: "accept" | "decline") => Promise; + submit: (dareId: string) => Promise; + complete: (dareId: string) => Promise; + cancel: (dareId: string) => Promise; +} + +export function useDares(): UseDaresReturn { + const [active, setActive] = useState([]); + const [history, setHistory] = useState([]); + const [catalog, setCatalog] = useState(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [busyDareId, setBusyDareId] = useState(null); + const [error, setError] = useState(null); + const [paywallReason, setPaywallReason] = useState(null); + const fetchingRef = useRef(false); + const mountedRef = useRef(true); + + const refresh = useCallback(async () => { + if (fetchingRef.current) return; + fetchingRef.current = true; + try { + const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([ + daresApi.list("active"), + daresApi.list("history"), + daresApi.catalog(), + daresApi.stats(), + ]); + if (!mountedRef.current) return; + setActive(activeResult.dares); + setHistory(historyResult.dares); + setCatalog(catalogResult); + setStats(statsResult); + setError(null); + } catch (cause) { + if (mountedRef.current) { + setError(cause instanceof ApiError ? cause.message : "Could not load dares"); + } + } finally { + fetchingRef.current = false; + if (mountedRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + mountedRef.current = true; + void refresh(); + const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS); + return () => { + mountedRef.current = false; + clearInterval(timer); + }; + }, [refresh]); + + /** Premium refusals steer to the paywall; everything else surfaces inline. */ + const handleFailure = useCallback((cause: unknown) => { + if (cause instanceof ApiError && (cause.status === 402 || cause.status === 403)) { + setPaywallReason(cause.message); + return; + } + setError(cause instanceof ApiError ? cause.message : "Something went wrong"); + }, []); + + const act = useCallback( + async (dareId: string, operation: () => Promise) => { + setBusyDareId(dareId); + try { + await operation(); + await refresh(); + } catch (cause) { + handleFailure(cause); + } finally { + if (mountedRef.current) setBusyDareId(null); + } + }, + [refresh, handleFailure], + ); + + const send = useCallback( + async (payload: SendDarePayload) => { + try { + await daresApi.send(payload); + await refresh(); + return true; + } catch (cause) { + handleFailure(cause); + return false; + } + }, + [refresh, handleFailure], + ); + + return { + active, + history, + catalog, + stats, + loading, + busyDareId, + error, + paywallReason, + clearPaywall: useCallback(() => setPaywallReason(null), []), + refresh, + send, + respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]), + submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]), + complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]), + cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]), + }; +} diff --git a/apps/mobile/src/features/dares/hooks/useIncomingDareCount.ts b/apps/mobile/src/features/dares/hooks/useIncomingDareCount.ts new file mode 100644 index 0000000..d440cbb --- /dev/null +++ b/apps/mobile/src/features/dares/hooks/useIncomingDareCount.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from "react"; +import { useFocusEffect } from "expo-router"; +import { daresApi } from "../../../lib/daresApi"; + +/** + * Lightweight counterpart to `useDares` for the home tile: one request on focus + * rather than the four-endpoint poll the full screen runs. + */ +export function useIncomingDareCount(enabled: boolean): number { + const [count, setCount] = useState(0); + + useFocusEffect( + useCallback(() => { + if (!enabled) return; + let cancelled = false; + daresApi + .list("active") + .then((result) => { + if (cancelled) return; + setCount( + result.dares.filter( + (dare) => dare.direction === "incoming" && dare.status === "pending", + ).length, + ); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [enabled]), + ); + + return count; +} diff --git a/apps/mobile/src/features/dares/index.ts b/apps/mobile/src/features/dares/index.ts new file mode 100644 index 0000000..670c43c --- /dev/null +++ b/apps/mobile/src/features/dares/index.ts @@ -0,0 +1,4 @@ +export { DaresScreen, default } from "./DaresScreen"; +export { useDares } from "./hooks/useDares"; +export type { UseDaresReturn } from "./hooks/useDares"; +export * from "./types"; diff --git a/apps/mobile/src/features/dares/types.ts b/apps/mobile/src/features/dares/types.ts new file mode 100644 index 0000000..06632a9 --- /dev/null +++ b/apps/mobile/src/features/dares/types.ts @@ -0,0 +1,83 @@ +export type DareStatus = + | "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled"; + +export interface DarePack { + id: string; + name: string; + description: string | null; + icon: string | null; + is_premium: boolean; + is_explicit: boolean; + sort_order: number; + category_id: string | null; + min_intensity: number | null; + max_intensity: number | null; + avg_intensity: number | null; + dare_count: number; +} + +export interface DareItem { + id: string; + pack_id: string; + text: string; + intensity: number; + suggested_duration_hours: number | null; +} + +export interface SentDare { + id: string; + couple_id: string; + dare_id: string | null; + text: string; + intensity: number; + is_custom: boolean; + sender_id: string; + recipient_id: string; + direction: "incoming" | "outgoing"; + status: DareStatus; + sender_notes: string | null; + sent_at: string; + accepted_at: string | null; + submitted_at: string | null; + completed_at: string | null; + expires_at: string | null; +} + +export interface DareEntitlement { + is_premium: boolean; + can_send_custom: boolean; + weekly_send_limit: number | null; + sends_remaining: number | null; +} + +export interface DareCatalog { + entitlement: DareEntitlement; + packs: DarePack[]; +} + +export interface DareStats { + sent: number; + received: number; + completed_together: number; + active: number; + completed_by_me: number; + completed_by_partner: number; +} + +export interface SendDarePayload { + dare_id?: string; + custom_dare_text?: string; + custom_dare_intensity?: number; + duration_hours?: number | null; + sender_notes?: string | null; +} + +export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [ + { label: "1 hour", hours: 1 }, + { label: "6 hours", hours: 6 }, + { label: "12 hours", hours: 12 }, + { label: "24 hours", hours: 24 }, + { label: "3 days", hours: 72 }, + { label: "1 week", hours: 168 }, + { label: "No time limit", hours: null }, +]; diff --git a/apps/mobile/src/features/index.ts b/apps/mobile/src/features/index.ts index 6a63247..8615c14 100644 --- a/apps/mobile/src/features/index.ts +++ b/apps/mobile/src/features/index.ts @@ -1,3 +1,4 @@ // Features exports export * from './chat'; +export * from './dares'; export * from './profile'; diff --git a/apps/mobile/src/lib/daresApi.ts b/apps/mobile/src/lib/daresApi.ts new file mode 100644 index 0000000..654d212 --- /dev/null +++ b/apps/mobile/src/lib/daresApi.ts @@ -0,0 +1,19 @@ +import { apiClient } from "./apiClient"; +import type { DareCatalog, DareItem, DareStats, SendDarePayload, SentDare } from "../features/dares/types"; + +const dare = (dareId: string) => `/v1/dares/${encodeURIComponent(dareId)}`; + +export const daresApi = { + catalog: () => apiClient.get("/v1/dares/packs"), + packDares: (packId: string) => + apiClient.get<{ dares: DareItem[] }>(`/v1/dares/packs/${encodeURIComponent(packId)}/dares`), + list: (filter: "active" | "history") => + apiClient.get<{ dares: SentDare[] }>(`/v1/dares?filter=${filter}`), + stats: () => apiClient.get("/v1/dares/stats"), + send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload), + respond: (dareId: string, action: "accept" | "decline") => + apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }), + submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`), + complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`), + cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`), +}; diff --git a/apps/web/package.json b/apps/web/package.json index 0a86a39..38fbf8e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,8 +20,8 @@ }, "devDependencies": { "@types/node": "^20.0.0", - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", + "@types/react": "~19.1.10", + "@types/react-dom": "~19.1.7", "autoprefixer": "^10.4.0", "eslint": "^8.57.1", "eslint-config-next": "14.2.35", diff --git a/docs/couple-streaks.md b/docs/couple-streaks.md index 91316ae..d7255bc 100644 --- a/docs/couple-streaks.md +++ b/docs/couple-streaks.md @@ -94,5 +94,5 @@ the old `couple_streaks` table. The API owns both jobs now. Those legacy writers stopped as part of the cutover procedure in [backend deployment and branching](operations/backend-deployments.md); if any hosted project still has them scheduled, an operator unschedules them directly — agents do not -execute remote DDL. Migration `0015_couple_streak_locality.sql` drops the equivalent +execute remote DDL. Migration `0016_couple_streak_locality.sql` drops the equivalent trigger and functions from the product database defensively. diff --git a/docs/dares-feature.md b/docs/dares-feature.md index 5cd464c..4453d3f 100644 --- a/docs/dares-feature.md +++ b/docs/dares-feature.md @@ -1,5 +1,10 @@ # Dares Feature - Architecture & Implementation Plan +> **SUPERSEDED.** This is the original 2026-01 plan, kept for its competitor analysis +> and decision history. It describes a Supabase Edge Function + RLS architecture that +> the product no longer uses: mobile talks only to the standalone API. For the shipped +> contract see [Dares loop](dares-loop.md). + > **Status**: Phase 1 Complete (Database + Admin Portal) > **Created**: 2026-01-08 > **Updated**: 2026-01-08 diff --git a/docs/dares-loop.md b/docs/dares-loop.md new file mode 100644 index 0000000..a4f6b59 --- /dev/null +++ b/docs/dares-loop.md @@ -0,0 +1,139 @@ +# Dares loop + +Maintained reference for the dares feature: a couple sends each other challenges, +each state change notifies the other partner, and the pair keeps a shared count of +what they have finished together. + +The asynchronous shape is the point. Every transition below is a push notification +to whichever partner is not holding the app. + +## Ownership + +| Concern | Location | +|---|---| +| Schema, triggers, review parity | `apps/api/drizzle/0013_dares_loop.sql` | +| HTTP contract | `apps/api/src/domains/dares/routes.ts` | +| Domain rules and entitlement | `apps/api/src/domains/dares/repository.ts` | +| Expiry sweep | `apps/api/src/domains/operations/repository.ts` (`produce`) | +| Mobile feature | `apps/mobile/src/features/dares/` | +| Catalogue authoring | `apps/admin/src/pages/content/DarePacksPage.tsx`, `DaresPage.tsx` | + +## State machine + +``` + ┌── decline ──► declined +pending ──accept──┤ + │ └── accept ───► active ──submit──► submitted ──confirm──► completed + │ │ │ + └──────────────── cancel ─────────┴───────────────────┘──► cancelled + (sender only, before completion) + +any open status + expires_at in the past ──► expired (worker sweep) +``` + +Actor rules are enforced server-side, not by hiding buttons: + +| Transition | Who | Legal from | +|---|---|---| +| accept / decline | recipient | `pending` | +| submit | recipient | `active` | +| complete | sender | `active`, `submitted` | +| cancel | sender | `pending`, `active`, `submitted` | + +The recipient performs the dare, so the recipient reports it done (`submitted`) and +the sender confirms (`completed`). An illegal transition returns `409 +invalid_transition`; acting on someone else's dare returns `403 not_permitted`; +a dare outside your couple returns `404`, never a hint that it exists. + +## Entitlement + +Premium is couple-shared — either partner's subscription unlocks both, matching the +answers domain. + +| Tier | Can do | +|---|---| +| Anyone, always | Receive, accept, decline, submit, complete, cancel | +| Free sender | Free packs, `FREE_WEEKLY_SEND_LIMIT` sends per rolling 7 days | +| Premium | All packs, unlimited sends, custom dares | + +**Receiving is never gated.** A paywall between a partner and the dare they were just +notified about would break the loop the feature exists to create. The quota is counted +per sender over a rolling window, and counts every send — cancelling does not refund it. + +## Content safety + +`dare_packs` and `dares` carry `content_status` (`unreviewed` / `allowed` / `archived`), +matching the legacy catalogue contract. The catalogue, the pack detail endpoint, and the +send path each independently require `allowed`, so a stale client cannot send unreviewed +content. New content defaults to `unreviewed` and is therefore invisible until an +administrator reviews it. + +Push copy is deliberately generic — `"Your partner sent you a dare"`, never the dare +text. Store compliance forbids question, dare, response, or message text in a +notification preview, and `dares.integration.test.ts` asserts no payload leaks it. + +## History durability + +`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time. +Editing, archiving, or deleting a catalogue dare never rewrites what a couple was +actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and +the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail +with a check violation. + +## Notifications + +`queue_dare_operations` (trigger on `sent_dares`) writes one `operations_outbox` row per +transition with dedupe key `dare:::`, gated on +`notification_preferences.dares_enabled`. Delivery, retry, and Expo ticket handling are +the existing worker's job — see `apps/api/src/domains/operations/README.md`. + +`expired` notifies both partners; every other transition notifies the one partner who +did not cause it. + +## Expiry + +The worker's `produce` pass expires any dare whose `expires_at` has passed while still +`pending`, `active`, or `submitted`, reported as `daresExpired` in `ProducerSummary`. +Pending is included deliberately: an invitation the recipient never opened would +otherwise stay open forever. + +Durations offered to the client are fixed presets (1, 6, 12, 24, 72, 168 hours, or none) +and validated server-side. + +## HTTP contract + +| Method | Path | Notes | +|---|---|---| +| GET | `/v1/dares/packs` | Catalogue plus the caller's entitlement | +| GET | `/v1/dares/packs/:packId/dares` | `402` when the pack is premium and the couple is not | +| GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller | +| GET | `/v1/dares/stats` | Shared scoreboard | +| GET | `/v1/dares/durations` | Preset list the send sheet renders | +| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both | +| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` | +| POST | `/v1/dares/:dareId/submit` | Recipient reports done | +| POST | `/v1/dares/:dareId/complete` | Sender confirms | +| POST | `/v1/dares/:dareId/cancel` | Sender withdraws | + +The actor always comes from the bearer identity; ownership fields in a request body are +rejected. + +## Mobile + +Reached from the home screen's `DaresTile`, which badges pending incoming dares. The +screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime +channel), and opens the paywall both proactively and in reaction to a `402` — a client +whose entitlement went stale still lands on the paywall rather than an error. + +## Tests + +- `apps/api/test/dares.integration.test.ts` — real PostgreSQL: entitlement, quota, + transitions, couple isolation, notification fan-out, no-text-in-push, snapshot durability +- `apps/api/test/dares.routes.test.ts` — HTTP contract and error mapping +- `apps/mobile/src/__tests__/hooks/useDares.test.ts` — loading, actions, paywall routing + +## Not in this version + +Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox +split from the main match chat was cut in favour of status transitions plus the sender's +note. Photo proof, random-dare, and streaks remain unbuilt. diff --git a/docs/index.md b/docs/index.md index aebd2d4..5b74a3f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,6 +21,7 @@ historical notes. Agents should load only the section relevant to their task. - [Subscriptions](subscription-system.md) - [Redemption codes](product/redemption.md) - [Mobile release process](releasing.md) +- [Dares loop](dares-loop.md) ## Feature and implementation references @@ -30,7 +31,7 @@ partially implemented states. Confirm claims against current code. - `admin-question-types-plan.md` - [Universal catalogue safety plan](product/catalog-content-safety-plan.md) - `content-balance-implementation.md` -- `dares-feature.md` +- `dares-feature.md` (superseded by [Dares loop](dares-loop.md)) - `live-draw-feature-plan.md` - `NEW_QUESTION_TYPES.md` - `oversized-file-inventory.md` diff --git a/package-lock.json b/package-lock.json index 69f7f00..42cdd86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,8 +78,8 @@ "tailwind-merge": "^2.2.0" }, "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "~19.1.10", + "@types/react-dom": "~19.1.7", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.16", "postcss": "^8.4.32", @@ -88,27 +88,6 @@ "vite": "^5.0.0" } }, - "apps/admin/node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "apps/admin/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "apps/admin/node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -725,8 +704,8 @@ }, "devDependencies": { "@types/node": "^20.0.0", - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", + "@types/react": "~19.1.10", + "@types/react-dom": "~19.1.7", "autoprefixer": "^10.4.0", "eslint": "^8.57.1", "eslint-config-next": "14.2.35", @@ -745,27 +724,6 @@ "undici-types": "~6.21.0" } }, - "apps/web/node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "apps/web/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "apps/web/node_modules/lucide-react": { "version": "0.460.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", @@ -838,6 +796,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.4.tgz", "integrity": "sha512-imSwulOeDQodRy/olQmVEo2PiY6ntjkZ9eiGdw6lMYylh/tay9b7MusyJBmEnkL8GiKRKr6ltr+D42mY5bd8Bg==", + "license": "MIT", "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" }, @@ -954,17 +913,17 @@ } }, "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", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -991,9 +950,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "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.28.6", @@ -1015,13 +974,13 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1057,12 +1016,12 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1080,6 +1039,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", @@ -1093,14 +1053,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1149,6 +1109,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", @@ -1272,14 +1233,14 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-decorators": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1289,12 +1250,12 @@ } }, "node_modules/@babel/plugin-proposal-export-default-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", - "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1355,12 +1316,12 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", - "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1373,6 +1334,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1381,12 +1343,12 @@ } }, "node_modules/@babel/plugin-syntax-export-default-from": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", - "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1396,12 +1358,12 @@ } }, "node_modules/@babel/plugin-syntax-flow": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", - "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1450,12 +1412,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1600,6 +1562,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", @@ -1616,6 +1579,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", @@ -1632,6 +1596,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1659,13 +1624,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1698,6 +1663,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/template": "^7.29.7" @@ -1710,13 +1676,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1726,12 +1692,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1741,13 +1707,13 @@ } }, "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", - "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-flow": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1760,6 +1726,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" @@ -1775,6 +1742,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", @@ -1791,6 +1759,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1805,6 +1774,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1835,6 +1805,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -1865,6 +1836,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1876,16 +1848,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1898,6 +1870,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1925,12 +1898,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1940,13 +1913,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1956,14 +1929,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1973,12 +1946,12 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1988,16 +1961,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2007,12 +1980,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", "license": "MIT", "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" + "@babel/plugin-transform-react-jsx": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2052,13 +2025,13 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2071,6 +2044,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -2082,13 +2056,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", - "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", @@ -2120,6 +2094,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" @@ -2135,6 +2110,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -2196,17 +2172,17 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz", + "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-react-display-name": "^7.29.7", + "@babel/plugin-transform-react-jsx": "^7.29.7", + "@babel/plugin-transform-react-jsx-development": "^7.29.7", + "@babel/plugin-transform-react-pure-annotations": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -3505,6 +3481,7 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", "dependencies": { "node-forge": "^1.3.3" } @@ -3550,23 +3527,6 @@ "xml2js": "0.6.0" } }, - "node_modules/@expo/config-plugins/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==", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@expo/config-plugins/node_modules/@expo/json-file": { - "version": "10.0.16", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", - "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" - } - }, "node_modules/@expo/config-plugins/node_modules/@expo/plist": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", @@ -3827,6 +3787,7 @@ "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" @@ -3836,6 +3797,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", "dependencies": { "ms": "^2.1.1" } @@ -3844,6 +3806,7 @@ "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": { "chalk": "^4.1.2" }, @@ -4026,6 +3989,25 @@ "node": ">=10" } }, + "node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/json-file/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": { + "@babel/highlight": "^7.10.4" + } + }, "node_modules/@expo/metro": { "version": "54.2.0", "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", @@ -4051,6 +4033,7 @@ "version": "54.0.17", "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.17.tgz", "integrity": "sha512-PQFgQCZGY0DffZUvBzJttDPreZfHrQakaBlKjnvOUMNXbDna+TYmg1IFZuIDUYJezLcdp+TvVFTLjNi1+mqaVw==", + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", @@ -4083,27 +4066,11 @@ } } }, - "node_modules/@expo/metro-config/node_modules/@expo/json-file": { - "version": "10.0.16", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", - "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" - } - }, - "node_modules/@expo/metro-config/node_modules/@expo/json-file/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==", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, "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": "18 || 20 || >=22" } @@ -4112,6 +4079,7 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -4123,6 +4091,7 @@ "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -4134,6 +4103,7 @@ "version": "11.0.7", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "license": "BSD-2-Clause", "dependencies": { "dotenv": "^16.4.5" }, @@ -4148,6 +4118,7 @@ "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": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -4164,6 +4135,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -4172,6 +4144,7 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" }, @@ -4186,6 +4159,7 @@ "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": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -4215,6 +4189,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -4429,6 +4404,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", "integrity": "sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==", + "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.8.0" }, @@ -4440,6 +4416,7 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.1.tgz", "integrity": "sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==", + "license": "MIT", "dependencies": { "@expo/json-file": "^11.0.1", "@expo/spawn-async": "^1.8.0", @@ -4453,6 +4430,7 @@ "version": "11.0.1", "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.1.tgz", "integrity": "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==", + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" @@ -4462,6 +4440,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", "engines": { "node": ">=6" } @@ -4470,6 +4449,7 @@ "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" }, @@ -4481,6 +4461,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", "dependencies": { "restore-cursor": "^2.0.0" }, @@ -4492,6 +4473,7 @@ "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" } @@ -4499,12 +4481,14 @@ "node_modules/@expo/package-manager/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==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" }, "node_modules/@expo/package-manager/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" } @@ -4513,6 +4497,7 @@ "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" } @@ -4521,6 +4506,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", "dependencies": { "chalk": "^2.0.1" }, @@ -4532,6 +4518,7 @@ "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", @@ -4545,6 +4532,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", "engines": { "node": ">=4" } @@ -4553,6 +4541,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", "dependencies": { "mimic-fn": "^1.0.0" }, @@ -4564,6 +4553,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", @@ -4580,6 +4570,7 @@ "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", @@ -4593,6 +4584,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" @@ -4605,6 +4597,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", "dependencies": { "ansi-regex": "^4.1.0" }, @@ -4616,6 +4609,7 @@ "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" }, @@ -4638,6 +4632,7 @@ "version": "54.0.9", "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.9.tgz", "integrity": "sha512-3/Rmyzt8vduPjnSVHbnc0wYFrlhwLWn2g596rDyKcLGeqN2WTLJbzVeznsrUwyzhBNXgnTomZWO5HDzbZ/4E7g==", + "license": "MIT", "dependencies": { "@expo/config": "~12.0.14", "@expo/config-plugins": "~54.0.5", @@ -4654,24 +4649,17 @@ "expo": "*" } }, - "node_modules/@expo/prebuild-config/node_modules/@expo/json-file": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", - "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, "node_modules/@expo/prebuild-config/node_modules/@react-native/normalize-colors": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", - "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==" + "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", + "license": "MIT" }, "node_modules/@expo/prebuild-config/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -4722,12 +4710,14 @@ "node_modules/@expo/sudo-prompt": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", - "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==" + "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": "*", @@ -4737,12 +4727,14 @@ "node_modules/@expo/ws-tunnel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", - "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==" + "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", + "license": "MIT" }, "node_modules/@expo/xcpretty": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", + "license": "BSD-3-Clause", "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", @@ -5639,6 +5631,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", "dependencies": { "minipass": "^7.0.4" }, @@ -9776,6 +9769,7 @@ "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", + "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" @@ -9788,6 +9782,7 @@ "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", + "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", @@ -9846,6 +9841,7 @@ "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" } @@ -10005,17 +10001,17 @@ "license": "MIT" }, "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.18.18", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.18.tgz", + "integrity": "sha512-8XEvMG7SeBEY1zPSM3Bt3T12db1Q+j9qAMakdoSVkbFBXo5+rbrsdz4kOVtCs7vXOEeJfOEIZlubgDb4zRFHAw==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.5", + "@react-navigation/elements": "^2.9.40", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.3.18", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -10023,12 +10019,12 @@ } }, "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.21.13", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.13.tgz", + "integrity": "sha512-d1syMXra7RlJjGsjsIxEA14F6U+awFtQJeOfDc+V7x3y1mPqYQVTYnqnt3PR+fBBouUKaKsgKZsSR3MP5919Xg==", "license": "MIT", "dependencies": { - "@react-navigation/routers": "^7.5.3", + "@react-navigation/routers": "^7.6.4", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", @@ -10042,9 +10038,9 @@ } }, "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==", + "version": "2.9.40", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.40.tgz", + "integrity": "sha512-x2fCMVKU23YeQvHSCFq2mVRYAbHqYNqb8kfWSEfIQ+XUX7dBXWd93/pr0w5jLjALDHDxQUaJKh4mIqr8q3zAwQ==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -10053,7 +10049,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.3.18", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -10065,15 +10061,16 @@ } }, "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==", + "version": "7.3.18", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.18.tgz", + "integrity": "sha512-5lLYl0Kt85jcpEYxXzj2ecIKta+yo4EcAIOZoJe6jU9lbR0lu6sAQsotU3rlVAYL1VBY/xLemxs4WQ2LJs4Oew==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.14.0", + "@react-navigation/core": "^7.21.13", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", + "standard-navigation": "^0.0.8", "use-latest-callback": "^0.2.4" }, "peerDependencies": { @@ -10082,18 +10079,18 @@ } }, "node_modules/@react-navigation/native-stack": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.11.0.tgz", - "integrity": "sha512-yNx9Wr4dfpOHpqjf2sGog4eH6KCYwTAEPlUPrKbvWlQbCRm5bglwPmaTXw9hTovX9v3HIa42yo7bXpbYfq4jzg==", + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.18.10.tgz", + "integrity": "sha512-dWH6bLHxdPslMmNCPi8doythC9gtavvh9LK8F0HIQLuYE5A2GOk1u6mrvnYrqyJBfsXa6gA8O0Zr6XGywynDnA==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.5", + "@react-navigation/elements": "^2.9.40", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.3.18", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -10101,9 +10098,9 @@ } }, "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==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.6.4.tgz", + "integrity": "sha512-GI7eJm8/KsZUQaYcXvEExikKurRZRgEsSzyZ7faENfi65yqJBCXjDMwyN1pF6pNW1MoLH1ErDwDivFxY6BzD3w==", "license": "MIT", "dependencies": { "nanoid": "^3.3.11" @@ -11255,13 +11252,6 @@ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", @@ -11912,6 +11902,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", + "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.13", "wonka": "^6.3.2" @@ -11921,6 +11912,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", + "license": "MIT", "dependencies": { "@urql/core": "^5.1.2", "wonka": "^6.3.2" @@ -12197,6 +12189,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz", "integrity": "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==", + "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" }, @@ -12760,13 +12753,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "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.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { @@ -12787,12 +12780,12 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "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.6" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -12802,6 +12795,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", "dependencies": { "@babel/types": "^7.26.0" } @@ -12809,7 +12803,8 @@ "node_modules/babel-plugin-react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", - "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==" + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT" }, "node_modules/babel-plugin-syntax-hermes-parser": { "version": "0.29.1", @@ -12858,6 +12853,7 @@ "version": "54.0.12", "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.12.tgz", "integrity": "sha512-6xeSkdaixmQhWSYL7tfLu0pOS0BY+8ftwmdNSHtpEFSizrXYZkCjk/B6Dxr+6nwNRihixMcS0aBlWS1wlDl3pw==", + "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", @@ -12954,18 +12950,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "license": "MIT", "dependencies": { "open": "^8.0.4" }, @@ -12977,6 +12977,7 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -13079,6 +13080,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", "dependencies": { "big-integer": "1.6.x" }, @@ -13109,9 +13111,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -13128,11 +13130,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -13344,9 +13346,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -13467,6 +13469,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } @@ -13889,12 +13892,15 @@ } }, "node_modules/core-js-compat": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", - "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" }, "funding": { "type": "opencollective", @@ -14443,6 +14449,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -14505,6 +14512,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", "engines": { "node": ">=8" } @@ -15280,9 +15288,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.283", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", - "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "version": "1.5.416", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", "license": "ISC" }, "node_modules/emittery": { @@ -15345,6 +15353,7 @@ "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", + "license": "MIT", "engines": { "node": ">=8" } @@ -16745,6 +16754,7 @@ "version": "12.0.13", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", + "license": "MIT", "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.13" @@ -16845,6 +16855,7 @@ "version": "18.0.14", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.14.tgz", "integrity": "sha512-BUZm9mkl/TX7zNaN0N4C83ws7mEnesCFiKhqK+rMZwTD2+Q4wWB2kBltqnC/LrCRaMyRi40XxhCRVwuKnJkxXQ==", + "license": "MIT", "dependencies": { "@expo/config": "~12.0.14", "@expo/env": "~2.0.12" @@ -16931,6 +16942,7 @@ "version": "19.0.24", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.24.tgz", "integrity": "sha512-hZs0Ng+coNjx7YJ8MPwfJpoU1SRul6OZ9LZycFzozUeoR+denbyr944YrMP9NVn+Zf1tdC7p0K0zgah3ZySOYg==", + "license": "MIT", "peerDependencies": { "expo": "*", "react-native": "*" @@ -16940,6 +16952,7 @@ "version": "14.0.12", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz", "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==", + "license": "MIT", "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -17006,6 +17019,7 @@ "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": "*" @@ -17097,6 +17111,7 @@ "version": "3.0.30", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz", "integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==", + "license": "MIT", "dependencies": { "invariant": "^2.2.4" }, @@ -17278,6 +17293,7 @@ "version": "54.0.27", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.27.tgz", "integrity": "sha512-BxKOr6e8wmUwhGSyEVKY+CHWgS3Cre9ibgx1QWcdCenKTVKAz62qGmYL51q0lCcKg6vQY+4Usw9RK5Il4ZFaIA==", + "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.6", @@ -17361,19 +17377,11 @@ } } }, - "node_modules/expo/node_modules/@expo/json-file": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", - "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, "node_modules/expo/node_modules/@expo/plist": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==", + "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", @@ -17384,6 +17392,7 @@ "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", "engines": { "node": ">=10.0.0" } @@ -17392,6 +17401,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", "engines": { "node": ">=6" } @@ -17400,6 +17410,7 @@ "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" }, @@ -17411,6 +17422,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -17419,6 +17431,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", "dependencies": { "restore-cursor": "^2.0.0" }, @@ -17430,6 +17443,7 @@ "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" } @@ -17437,12 +17451,14 @@ "node_modules/expo/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==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" }, "node_modules/expo/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" } @@ -17451,6 +17467,7 @@ "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": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -17467,6 +17484,7 @@ "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" } @@ -17475,6 +17493,7 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -17486,6 +17505,7 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" }, @@ -17500,6 +17520,7 @@ "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" } @@ -17508,6 +17529,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", "dependencies": { "chalk": "^2.0.1" }, @@ -17519,6 +17541,7 @@ "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", @@ -17532,6 +17555,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -17540,6 +17564,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", "engines": { "node": ">=4" } @@ -17548,6 +17573,7 @@ "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.2" }, @@ -17562,6 +17588,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", "dependencies": { "mimic-fn": "^1.0.0" }, @@ -17573,6 +17600,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", @@ -17589,6 +17617,7 @@ "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", @@ -17602,6 +17631,7 @@ "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": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -17625,6 +17655,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" @@ -17637,6 +17668,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -17648,6 +17680,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", "dependencies": { "ansi-regex": "^4.1.0" }, @@ -17659,6 +17692,7 @@ "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" }, @@ -17670,6 +17704,7 @@ "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", "engines": { "node": ">=8.0" } @@ -18546,6 +18581,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", + "license": "MIT", "engines": { "node": ">=8" } @@ -19036,6 +19072,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" }, @@ -19046,7 +19083,8 @@ "node_modules/hosted-git-info/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==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", @@ -19352,7 +19390,8 @@ "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" }, "node_modules/internal-slot": { "version": "1.1.0", @@ -21726,6 +21765,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", + "license": "MIT", "bin": { "lan-network": "dist/lan-network-cli.js" } @@ -22959,6 +22999,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", "dependencies": { "minipass": "^7.1.2" }, @@ -23082,7 +23123,8 @@ "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==" + "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", + "license": "MIT" }, "node_modules/next": { "version": "14.2.35", @@ -23237,6 +23279,7 @@ "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" } @@ -23248,10 +23291,13 @@ "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==", - "license": "MIT" + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/node-stream-zip": { "version": "1.15.0", @@ -23291,6 +23337,7 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", @@ -23305,6 +23352,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -24520,6 +24568,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "license": "MIT", "engines": { "node": ">=6" }, @@ -24563,6 +24612,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -24571,6 +24621,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -24824,6 +24875,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -24838,6 +24890,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -25157,6 +25210,7 @@ "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" @@ -25656,6 +25710,7 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", + "license": "MIT", "dependencies": { "path-parse": "^1.0.5" } @@ -26595,6 +26650,15 @@ "node": ">=8" } }, + "node_modules/standard-navigation": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.8.tgz", + "integrity": "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -26952,7 +27016,8 @@ "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", - "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==" + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" }, "node_modules/styled-jsx": { "version": "5.1.1", @@ -27065,6 +27130,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -27187,6 +27253,7 @@ "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -27202,6 +27269,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } @@ -27210,6 +27278,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" @@ -28904,6 +28973,7 @@ "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", "engines": { "node": ">=18.17" } @@ -29008,9 +29078,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", @@ -29184,6 +29254,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -29685,7 +29756,8 @@ "node_modules/wonka": { "version": "6.3.6", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", - "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==" + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", + "license": "MIT" }, "node_modules/word-wrap": { "version": "1.2.5", diff --git a/scripts/fix-hoisting.js b/scripts/fix-hoisting.js index 3281eea..c071b52 100644 --- a/scripts/fix-hoisting.js +++ b/scripts/fix-hoisting.js @@ -15,6 +15,7 @@ const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const MOBILE_NM = path.join(ROOT, 'apps', 'mobile', 'node_modules'); +const WEB_NM = path.join(ROOT, 'apps', 'web', 'node_modules'); const ROOT_NM = path.join(ROOT, 'node_modules'); // Packages that must live in apps/mobile/node_modules, not root @@ -40,3 +41,25 @@ for (const pkg of PACKAGES_TO_FIX) { fs.renameSync(rootPkg, localPkg); console.log(`fix-hoisting: moved ${pkg} → apps/mobile/node_modules/`); } + +// Next and its React-facing tooling must resolve the web workspace's React 18 +// runtime, not the React 19 runtime hoisted for Expo SDK 54 / React Native. +for (const pkg of ['next', 'styled-jsx', 'eslint-config-next']) { + const rootPkg = path.join(ROOT_NM, pkg); + const localPkg = path.join(WEB_NM, pkg); + if (!fs.existsSync(rootPkg) || fs.existsSync(localPkg)) continue; + + fs.mkdirSync(WEB_NM, { recursive: true }); + fs.renameSync(rootPkg, localPkg); + console.log(`fix-hoisting: moved ${pkg} → apps/web/node_modules/`); +} + +const localNext = path.join(WEB_NM, 'next'); +if (fs.existsSync(localNext)) { + const localBinDir = path.join(WEB_NM, '.bin'); + const localBin = path.join(localBinDir, 'next'); + fs.mkdirSync(localBinDir, { recursive: true }); + if (!fs.existsSync(localBin)) { + fs.symlinkSync(path.join('..', 'next', 'dist', 'bin', 'next'), localBin); + } +}