diff --git a/.claude/skills/doctor-explain/SKILL.md b/.claude/skills/doctor-explain/SKILL.md new file mode 100644 index 000000000..c8608ef07 --- /dev/null +++ b/.claude/skills/doctor-explain/SKILL.md @@ -0,0 +1,75 @@ +--- +name: doctor-explain +description: Explain React Doctor rules and configure which ones run via doctor.config.* (or package.json#reactDoctor). Use when the user types `/doctor-explain` or `/doctor-config`, asks why a rule fired, disagrees with a rule, wants to disable/enable a rule, silence a category or tag, tune CI/PR noise, or asks "what does this rule mean". Covers the `react-doctor rules` CLI (list, explain, set, enable, disable, category, ignore-tag) and how config layers combine: ignore.tags disables matching rules before linting, rules over categories sets severity, surfaces controls visibility only. +version: "1.0.0" +--- + +# Doctor Explain + +Explains React Doctor rules and edits `doctor.config.*` safely. Use this when a user wants to understand a rule or change which rules run — not for fixing diagnostics (that is the `react-doctor` skill / `/doctor`). + +Triggers: `/doctor-explain`, `/doctor-config`, "why did this rule fire", "I disagree with this rule", "turn this rule off", "stop flagging X", "too noisy", "disable design rules". + +## Workflow + +1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`). +2. Explain it before changing anything: + +```bash +npx react-doctor@latest rules explain react-doctor/no-array-index-as-key +``` + +3. Pick the narrowest control that matches the user's intent (see decision guide). +4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting). +5. Validate the change did what they wanted: + +```bash +npx react-doctor@latest --verbose --diff +``` + +## Commands + +```bash +npx react-doctor@latest rules list # every rule + its effective severity +npx react-doctor@latest rules list --configured # only what your config changed +npx react-doctor@latest rules list --category Performance # filter by category +npx react-doctor@latest rules explain # why it matters + how to configure +npx react-doctor@latest rules disable # rule never runs +npx react-doctor@latest rules enable # turn back on at its recommended severity +npx react-doctor@latest rules set warn # off | warn | error +npx react-doctor@latest rules category "React Native" off # whole category +npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …) +npx react-doctor@latest rules unignore-tag design +``` + +Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`). + +## Decision guide + +Match the control to the intent — prefer the narrowest one: + +- **User disagrees with one rule / it's a false positive for them** → `rules disable ` (sets `rules. = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule". +- **Rule is fine but wrong severity** → `rules set warn` or `rules set error`. +- **A disabled-by-default rule they want on** → `rules enable `. +- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "" off`. +- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag `. +- **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output. + +How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs. + +## Config shape + +Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`: + +```ts +// doctor.config.ts +export default { + rules: { "react-doctor/no-array-index-as-key": "off" }, + categories: { "React Native": "warn" }, + ignore: { tags: ["design"] }, +}; +``` + +## Educating the user + +When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules//.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues. diff --git a/.claude/skills/react-doctor/SKILL.md b/.claude/skills/react-doctor/SKILL.md new file mode 100644 index 000000000..3afcd67aa --- /dev/null +++ b/.claude/skills/react-doctor/SKILL.md @@ -0,0 +1,50 @@ +--- +name: react-doctor +description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook. +version: "1.1.0" +--- + +# React Doctor + +Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. + +## After making React code changes: + +Run `npx react-doctor@latest --verbose --diff` and check the score did not regress. + +If the score dropped, fix the regressions before committing. + +## For general cleanup or code improvement: + +Run `npx react-doctor@latest --verbose` (without `--diff`) to scan the full codebase. Fix issues by severity — errors first, then warnings. + +## /doctor — full local triage workflow + +When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it: + +```bash +curl --fail --silent --show-error \ + --header 'Cache-Control: no-cache' \ + https://www.react.doctor/prompts/react-doctor-agent.md +``` + +The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed. + +Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe. + +## Configuring or explaining rules + +When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), use the `doctor-explain` skill (alias `/doctor-config`). Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). + +## Command + +```bash +npx react-doctor@latest --verbose --diff +``` + +| Flag | Purpose | +| ----------- | --------------------------------------------- | +| `.` | Scan current directory | +| `--verbose` | Show affected files and line numbers per rule | +| `--diff` | Only scan changed files vs base branch | +| `--score` | Output only the numeric score | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72c6fcdaa..b521cdf8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -882,6 +882,7 @@ jobs: runs-on: ubuntu-latest needs: [determine-release-type, validate-production, publish-to-npm] if: >- + !cancelled() && needs.determine-release-type.outputs.release_type == 'production' && needs.publish-to-npm.result == 'success' && needs.validate-production.outputs.is_dry_run != 'true' diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index a2f590828..6c0793d2d 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 1b5239980..6c0793d2d 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index ff10afd6e..59172bac7 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 115a4c768..59172bac7 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index dcd3cd808..0f0904cab 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 459ca609d..0f0904cab 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 8ca12fe02..b7781968e 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 8e19b410a..b7781968e 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b824ebdd4..af76e18a2 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 4c19a13c2..af76e18a2 100644 Binary files a/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/demos/appsflyer-react-native-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/demos/appsflyer-react-native-app/components/AppsFlyer.js b/demos/appsflyer-react-native-app/components/AppsFlyer.js index f553dcffb..80696c83c 100644 --- a/demos/appsflyer-react-native-app/components/AppsFlyer.js +++ b/demos/appsflyer-react-native-app/components/AppsFlyer.js @@ -4,6 +4,7 @@ import appsFlyer, { MEDIATION_NETWORK, } from 'react-native-appsflyer'; import {Platform} from 'react-native'; +import {DEV_KEY, APP_ID} from '@env'; // events export const AF_viewCart = 'af_view_cart'; @@ -14,11 +15,11 @@ export const AF_clickOnItem = 'af_click_on_item'; const initOptions = { isDebug: true, - devKey: 'Us4GmXxXx46Qed', + devKey: DEV_KEY, onInstallConversionDataListener: true, timeToWaitForATTUserAuthorization: 10, onDeepLinkListener: true, - appId: '741993747', + appId: APP_ID, }; // AppsFlyer initialization flow. ends with initSdk. @@ -27,10 +28,12 @@ export function AFInit() { appsFlyer.setCurrentDeviceLanguage('EN'); } //appsFlyer.setAppInviteOneLinkID('oW4R'); - appsFlyer.initSdk(initOptions, + appsFlyer.initSdk(initOptions, (success) => { console.log("init SDK success", success); - }, + // Demonstrate logAdRevenue once after init — not on every in-app event. + AFLogAdRevenue(); + }, (error) =>{ console.log("init SDK failed", error); }); @@ -38,7 +41,7 @@ export function AFInit() { // AppsFlyer Purchase Connector initialization flow export function PCInit() { - const purchaseConnectorConfig: PurchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ + const purchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ logSubscriptions: true, logInApps: true, sandbox: true, @@ -58,10 +61,9 @@ export function AFLogEvent(name, values) { (err) => { console.log(err); }); - AFLogAdRevenue(); } -export function AFLogAdRevenue() { +function AFLogAdRevenue() { const adRevenueData = { monetizationNetwork: 'AF-AdNetwork', mediationNetwork: MEDIATION_NETWORK.DIRECT_MONETIZATION_NETWORK, diff --git a/demos/appsflyer-react-native-app/components/Cart.js b/demos/appsflyer-react-native-app/components/Cart.js index 1c1b84741..f5701bf4c 100644 --- a/demos/appsflyer-react-native-app/components/Cart.js +++ b/demos/appsflyer-react-native-app/components/Cart.js @@ -1,6 +1,6 @@ /* @flow weak */ -import React from 'react'; -import {View, StyleSheet, ScrollView, Alert, Platform} from 'react-native'; +import React, {useCallback, useMemo, useState} from 'react'; +import {View, Text, StyleSheet, FlatList, Pressable, Platform} from 'react-native'; import {ListItem, Avatar, Button} from 'react-native-elements'; import { getSubscriptions, @@ -9,9 +9,73 @@ import { RequestPurchase, finishTransaction, } from 'react-native-iap'; +import Confetti from './Confetti'; + +// Memoized row: re-renders only when its product or remove handler changes, so +// the per-row source object and press handler aren't rebuilt on every list pass. +const CartRow = React.memo(({group, onRemove}) => { + const {product, quantity} = group; + const imageSource = useMemo(() => ({uri: product.image}), [product.image]); + const handlePress = useCallback(() => onRemove(product), [onRemove, product]); + return ( + + }> + + {quantity > 1 && ( + + {`×${quantity}`} + + )} + + {product.name} + {quantity > 1 && ( + + {`${formatPrice(product.price)} USD each`} + + )} + + + {`${formatPrice(product.price * quantity)} USD`} + + + ); +}); + +// Collapse duplicate cart lines (same name + price) into one row with a quantity. +const groupCart = items => { + const map = new Map(); + items.forEach(item => { + const key = `${item.name}|${item.price}`; + const existing = map.get(key); + if (existing) { + existing.quantity += 1; + } else { + map.set(key, {key, product: item, quantity: 1}); + } + }); + return Array.from(map.values()); +}; + +const formatPrice = n => (Number.isInteger(n) ? n : n.toFixed(2)); + +const groupKeyExtractor = group => group.key; const Cart = ({route, navigation}) => { const {productList, removeProductFromCart, checkout} = route.params; + const [summary, setSummary] = useState(null); /* // Added methods @@ -62,61 +126,106 @@ const Cart = ({route, navigation}) => { }; */ - const handleRemove = product => { - removeProductFromCart(product); - navigation.goBack(); - }; + const total = useMemo( + () => productList.reduce((sum, p) => sum + p.price, 0), + [productList], + ); - const handleCheckout = () => { - if (productList.length !== 0) { - checkout(); + const groups = useMemo(() => groupCart(productList), [productList]); + + const handleRemove = useCallback( + product => { + removeProductFromCart(product); navigation.goBack(); - } else { - Alert.alert( - 'Cart Empty', - 'The cart is empty.', - [ - { - text: 'OK', - onPress: () => console.log('OK Pressed'), - style: 'cancel', - }, - ], - { - cancelable: true, - onDismiss: () => console.log('Alert dismissed'), - }, - ); - } + }, + [removeProductFromCart, navigation], + ); + + const renderItem = useCallback( + ({item}) => , + [handleRemove], + ); + + const handleCheckout = () => { + // Snapshot the cart for the summary before checkout() clears the source list. + setSummary({groups: groupCart(productList), total, count: productList.length}); + checkout(); }; + if (productList.length === 0 && !summary) { + return ( + + 🛒 + Your cart is empty + + Browse the shop and add a few items to get started. + + [styles.browseBtn, pressed && styles.pressed]} + onPress={() => navigation.goBack()}> + Browse products + + + ); + } + return ( - - {productList.map((product, index) => ( - handleRemove(product)} - /> - }> - - - {product.name} - {`${product.price} USD`} - - - ))} - -