From 7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 Mon Sep 17 00:00:00 2001 From: JustZvan Date: Fri, 6 Feb 2026 13:22:33 +0100 Subject: feat: initial commit! --- lib/auth.tsx | 81 +++++ lib/device.tsx | 90 ++++++ lib/locales.ts | 247 +++++++++++++++ lib/notifications.ts | 206 +++++++++++++ lib/theme.ts | 24 ++ lib/ui.tsx | 846 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1494 insertions(+) create mode 100644 lib/auth.tsx create mode 100644 lib/device.tsx create mode 100644 lib/locales.ts create mode 100644 lib/notifications.ts create mode 100644 lib/theme.ts create mode 100644 lib/ui.tsx (limited to 'lib') diff --git a/lib/auth.tsx b/lib/auth.tsx new file mode 100644 index 0000000..b05c6a7 --- /dev/null +++ b/lib/auth.tsx @@ -0,0 +1,81 @@ +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from "react"; +import { apiClient } from "../api/client"; + +type AuthState = { + isLoading: boolean; + isAuthenticated: boolean; +}; + +type AuthContextType = AuthState & { + signIn: ( + email: string, + password: string, + ) => Promise<{ success: boolean; reason?: string }>; + signUp: ( + email: string, + password: string, + ) => Promise<{ success: boolean; reason?: string }>; + signOut: () => Promise; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + isLoading: true, + isAuthenticated: false, + }); + + useEffect(() => { + // Check if user is already authenticated + const checkAuth = async () => { + await apiClient.initialize(); + setState({ + isLoading: false, + isAuthenticated: apiClient.isAuthenticated(), + }); + }; + checkAuth(); + }, []); + + const signIn = async (email: string, password: string) => { + const result = await apiClient.signIn(email, password); + if (result.success) { + setState({ isLoading: false, isAuthenticated: true }); + } + return { success: result.success, reason: result.reason }; + }; + + const signUp = async (email: string, password: string) => { + const result = await apiClient.signUp(email, password); + if (result.success) { + setState({ isLoading: false, isAuthenticated: true }); + } + return { success: result.success, reason: result.reason }; + }; + + const signOut = async () => { + await apiClient.signOut(); + setState({ isLoading: false, isAuthenticated: false }); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/lib/device.tsx b/lib/device.tsx new file mode 100644 index 0000000..e0ec600 --- /dev/null +++ b/lib/device.tsx @@ -0,0 +1,90 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { apiClient, getDevices } from "../api"; +import { Device } from "../api/types"; +import { useAuth } from "./auth"; + +type DeviceContextType = { + devices: Device[]; + selectedDevice: Device | null; + isLoading: boolean; + selectDevice: (device: Device) => Promise; + refreshDevices: () => Promise; +}; + +const DeviceContext = createContext(null); + +export function DeviceProvider({ children }: { children: ReactNode }) { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { isAuthenticated } = useAuth(); + + const refreshDevices = useCallback(async () => { + if (!isAuthenticated) { + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const fetchedDevices = await getDevices(); + setDevices(fetchedDevices); + + // If no device is selected, try to restore from storage or select first device + if (!selectedDevice && fetchedDevices.length > 0) { + const savedDeviceId = apiClient.getSelectedDeviceId(); + const savedDevice = fetchedDevices.find((d) => d.id === savedDeviceId); + + if (savedDevice) { + setSelectedDevice(savedDevice); + } else { + // Select first device by default + setSelectedDevice(fetchedDevices[0]!); + await apiClient.setSelectedDevice(fetchedDevices[0]!.id); + } + } + } catch (e) { + console.error("Failed to refresh devices", e); + } finally { + setIsLoading(false); + } + }, [selectedDevice, isAuthenticated]); + + useEffect(() => { + refreshDevices(); + }, [refreshDevices]); + + const selectDevice = useCallback(async (device: Device) => { + setSelectedDevice(device); + await apiClient.setSelectedDevice(device.id); + }, []); + + return ( + + {children} + + ); +} + +export function useDevice() { + const context = useContext(DeviceContext); + if (!context) { + throw new Error("useDevice must be used within a DeviceProvider"); + } + return context; +} diff --git a/lib/locales.ts b/lib/locales.ts new file mode 100644 index 0000000..d78621f --- /dev/null +++ b/lib/locales.ts @@ -0,0 +1,247 @@ +import { getLocales } from "expo-localization"; + +const en = { + home: "Home", + alerts: "Alerts", + controls: "Controls", + settings: "Settings", + allClear: "All clear", + attentionNeeded: "Attention needed", + captchaError: "Captcha verification failed. Please try again.", + today: "Today", + review: "Review", + nothingNeedsAttention: "Nothing needs attention.", + fewItemsNeedReview: "A few items need review.", + deviceIsOnline: "Device is online", + deviceIsOffline: "Device is offline", + protectionActive: "Protection active.", + reconnectingAutomatically: "Reconnecting automatically.", + recentAlerts: "Recent alerts", + last24Hours: "Last 24 hours", + thisWeek: "This week", + needsAction: "needs action", + reviewed: "reviewed", + // Onboarding & Auth + welcomeTitle: "Welcome to Buddy", + welcomeSubtitle: "Keep your family safe online with smart parental controls.", + getStarted: "Get Started", + signIn: "Sign In", + signUp: "Sign Up", + email: "Email", + password: "Password", + confirmPassword: "Confirm Password", + emailPlaceholder: "Enter your email", + passwordPlaceholder: "Enter your password", + confirmPasswordPlaceholder: "Confirm your password", + createAccount: "Create Account", + alreadyHaveAccount: "Already have an account?", + dontHaveAccount: "Don't have an account?", + invalidEmail: "Please enter a valid email", + passwordRequired: "Password is required", + passwordTooShort: "Password must be at least 8 characters", + passwordsDoNotMatch: "Passwords do not match", + signInError: "Unable to sign in. Please check your credentials.", + signUpError: "Unable to create account. Please try again.", + loadingAlerts: "Loading alerts...", + allClearTitle: "All clear!", + noAlertsToReview: "No alerts to review right now.", + reviewPill: "Review", + fyiPill: "FYI", + whatHappened: "What happened", + whyItMatters: "Why it matters", + suggestedNextStep: "Suggested next step", + noDeviceSelected: "No device selected. Please link a device first.", + disableBuddy: "Disable Buddy", + temporarilyDisablesBuddy: "Temporarily disables Buddy.", + contentBlocking: "Content blocking", + adultSites: "Adult sites", + blockAdultWebsites: "Block adult websites.", + familyLink: "Family Link", + antiCircumvention: "Anti-Circumvention", + preventFamilyLinkBypasses: + "Measures that prevent certain Family Link bypasses.", + communication: "Communication", + communicationWithStrangers: "Communication with strangers", + blockAllCommunications: "Block all communications", + scanCommunicationsWithAI: "Scan communications with AI", + chooseHowBuddyShouldHandleStrangers: + "Choose how Buddy should handle strangers.", + communicationWithStrangersTitle: "Communication with strangers", + blockAllCommunicationsConfirm: + "This will notify of all comms with strangers.", + okay: "Okay", + cancel: "Cancel", + notifications: "Notifications", + dangerousMessages: "Dangerous messages", + newContactAdded: "New contact added", + devices: "Devices", + device: "device", + devicesPlural: "devices", + lastSeen: "Last seen", + online: "Online", + offline: "Offline", + transparency: "Transparency", + privacyAndTerms: "Privacy & terms", + legalAndPrivacyInfo: "Legal and privacy info.", + account: "Account", + verifyEmail: "Verify E-Mail", + verifyYourEmailAddress: "Verify your email address.", + signOut: "Sign Out", + notWiredYet: "Not wired yet", + hookThisUpLater: "Hook this up later.", + renameDevice: "Rename Device", + enterNewNameFor: "Enter a new name for", + verifyEmailTitle: "Verify Email", + enterVerificationCode: "Enter the verification code sent to your email:", + success: "Success", + error: "Error", + emailVerifiedSuccessfully: "Email verified successfully!", + failedToVerifyEmail: "Failed to verify email", + newContactAddedTitle: "New Contact Added", + newContactAddedSubtitle: "A new contact was added to a device", + contactName: "Contact Name", + unknown: "Unknown", + phoneNumber: "Phone Number", + emailAddress: "Email Address", + identifier: "Identifier", + notProvided: "Not provided", + unknownDevice: "Unknown Device", + contactAddedInfo: + "This contact was added to {deviceName}. You can review it in the device's contact list.", + backToHome: "Back to Home", + noDeviceSelectedPleaseLinkFirst: + "No device selected. Please link a device first.", +}; + +const hr: typeof en = { + home: "Početna", + alerts: "Upozorenja", + controls: "Kontrole", + settings: "Postavke", + allClear: "Sve je u redu", + attentionNeeded: "Potrebna pažnja", + captchaError: "Neuspjela provjera Captcha. Pokušajte ponovno.", + today: "Danas", + review: "Pregled", + nothingNeedsAttention: "Ništa ne zahtijeva pažnju.", + fewItemsNeedReview: "Nekoliko stavki treba pregled.", + deviceIsOnline: "Uređaj je na mreži", + deviceIsOffline: "Uređaj je izvan mreže", + protectionActive: "Zaštita je aktivna.", + reconnectingAutomatically: "Automatsko ponovno povezivanje.", + recentAlerts: "Nedavna upozorenja", + last24Hours: "Zadnja 24 sata", + thisWeek: "Ovaj tjedan", + needsAction: "zahtijeva radnju", + reviewed: "pregledano", + // Onboarding & Auth + welcomeTitle: "Dobrodošli u Buddy", + welcomeSubtitle: + "Zaštitite svoju obitelj na internetu uz pametne roditeljske kontrole.", + getStarted: "Započni", + signIn: "Prijava", + signUp: "Registracija", + email: "E-mail", + password: "Lozinka", + confirmPassword: "Potvrdite lozinku", + emailPlaceholder: "Unesite svoj e-mail", + passwordPlaceholder: "Unesite svoju lozinku", + confirmPasswordPlaceholder: "Potvrdite svoju lozinku", + createAccount: "Izradi račun", + alreadyHaveAccount: "Već imate račun?", + dontHaveAccount: "Nemate račun?", + invalidEmail: "Unesite valjanu e-mail adresu", + passwordRequired: "Lozinka je obavezna", + passwordTooShort: "Lozinka mora imati najmanje 8 znakova", + passwordsDoNotMatch: "Lozinke se ne podudaraju", + signInError: "Prijava nije uspjela. Provjerite podatke.", + signUpError: "Registracija nije uspjela. Pokušajte ponovno.", + loadingAlerts: "Učitavanje upozorenja...", + allClearTitle: "Sve je u redu!", + noAlertsToReview: "Trenutno nema upozorenja za pregled.", + reviewPill: "Pregled", + fyiPill: "Informacija", + whatHappened: "Što se dogodilo", + whyItMatters: "Zašto je važno", + suggestedNextStep: "Predloženi sljedeći korak", + noDeviceSelected: "Nije odabran uređaj. Najprije povežite uređaj.", + disableBuddy: "Onemogući Buddy", + temporarilyDisablesBuddy: "Privremeno onemogućuje Buddy.", + contentBlocking: "Blokiranje sadržaja", + adultSites: "Stranice za odrasle", + blockAdultWebsites: "Blokiraj web-stranice za odrasle.", + familyLink: "Family Link", + antiCircumvention: "Protiv zaobilaženja", + preventFamilyLinkBypasses: + "Mjere koje sprječavaju određene zaobilaske Family Linka.", + communication: "Komunikacija", + communicationWithStrangers: "Komunikacija sa strancima", + blockAllCommunications: "Blokiraj sve komunikacije", + scanCommunicationsWithAI: "Skeniraj komunikacije pomoću AI", + chooseHowBuddyShouldHandleStrangers: + "Odaberite kako Buddy treba postupati sa strancima.", + communicationWithStrangersTitle: "Komunikacija sa strancima", + blockAllCommunicationsConfirm: + "Ovo će obavještavati o svim komunikacijama sa strancima.", + okay: "U redu", + cancel: "Odustani", + notifications: "Obavijesti", + dangerousMessages: "Opasne poruke", + newContactAdded: "Dodan novi kontakt", + devices: "Uređaji", + device: "uređaj", + devicesPlural: "uređaji", + lastSeen: "Zadnje viđeno", + online: "Na mreži", + offline: "Izvan mreže", + transparency: "Transparentnost", + privacyAndTerms: "Privatnost i uvjeti", + legalAndPrivacyInfo: "Pravne i privatnosne informacije.", + account: "Račun", + verifyEmail: "Potvrdi e-mail", + verifyYourEmailAddress: "Potvrdite svoju e-mail adresu.", + signOut: "Odjava", + notWiredYet: "Još nije povezano", + hookThisUpLater: "Povežite ovo kasnije.", + renameDevice: "Preimenuj uređaj", + enterNewNameFor: "Unesite novo ime za", + verifyEmailTitle: "Potvrdite e-mail", + enterVerificationCode: "Unesite verifikacijski kod poslan na vaš e-mail:", + success: "Uspjeh", + error: "Greška", + emailVerifiedSuccessfully: "E-mail je uspješno potvrđen!", + failedToVerifyEmail: "Neuspjela potvrda e-maila", + newContactAddedTitle: "Dodan novi kontakt", + newContactAddedSubtitle: "Na uređaj je dodan novi kontakt", + contactName: "Ime kontakta", + unknown: "Nepoznato", + phoneNumber: "Telefonski broj", + emailAddress: "E-mail adresa", + identifier: "Identifikator", + notProvided: "Nije navedeno", + unknownDevice: "Nepoznati uređaj", + contactAddedInfo: + "Ovaj kontakt je dodan na {deviceName}. Možete ga pregledati na popisu kontakata uređaja.", + backToHome: "Natrag na početnu", + noDeviceSelectedPleaseLinkFirst: + "Nije odabran uređaj. Najprije povežite uređaj.", +}; + +type Locale = "en" | "hr"; + +export const locales: Record = { + en, + hr, +} as const; + +export type LocaleKey = keyof typeof en; + +export const getCurrentLocale = (): Locale => { + const currentLocale = getLocales()[0]?.languageTag || "en"; + return currentLocale.startsWith("hr") ? "hr" : "en"; +}; + +export const t = (key: LocaleKey): string => { + const locale = getCurrentLocale(); + return locales[locale][key] || locales.en[key] || key; +}; diff --git a/lib/notifications.ts b/lib/notifications.ts new file mode 100644 index 0000000..a9c9a95 --- /dev/null +++ b/lib/notifications.ts @@ -0,0 +1,206 @@ +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; +import { apiClient } from "../api/client"; + +// Configure notification handling behavior +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +export type NotificationPermissionStatus = + | "granted" + | "denied" + | "undetermined"; + +/** + * Get current notification permission status + */ +export async function getNotificationPermissionStatus(): Promise { + const { status } = await Notifications.getPermissionsAsync(); + return status; +} + +/** + * Request notification permissions from the user + */ +export async function requestNotificationPermissions(): Promise { + // Check if we're on a physical device (notifications don't work on simulator) + if (!Device.isDevice) { + console.log("Push notifications require a physical device"); + return "denied"; + } + + // Get existing permissions first + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + + let finalStatus = existingStatus; + + // Only ask if permissions have not already been determined + if (existingStatus !== "granted") { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + return finalStatus; +} + +/** + * Get the Expo push token for this device + */ +export async function getExpoPushToken(): Promise { + if (!Device.isDevice) { + console.log("Push notifications require a physical device"); + return null; + } + + const { status } = await Notifications.getPermissionsAsync(); + if (status !== "granted") { + console.log("Notification permissions not granted"); + return null; + } + + try { + // Get the project ID from Expo config + const projectId = + Constants.expoConfig?.extra?.eas?.projectId ?? + Constants.easConfig?.projectId; + + if (!projectId) { + console.error("Project ID not found in Expo config"); + return null; + } + + const tokenData = await Notifications.getExpoPushTokenAsync({ + projectId, + }); + + return tokenData.data; + } catch (error) { + console.error("Failed to get Expo push token:", error); + return null; + } +} + +/** + * Register push token with the backend + */ +export async function registerPushToken(): Promise { + const token = await getExpoPushToken(); + + if (!token) { + console.log("No push token available to register"); + return false; + } + + try { + await apiClient.post("/parent/push-token", { token }); + console.log("Push token registered successfully"); + return true; + } catch (error) { + console.error("Failed to register push token:", error); + return false; + } +} + +/** + * Remove push token from the backend + */ +export async function unregisterPushToken(): Promise { + const token = await getExpoPushToken(); + + if (!token) { + console.log("No push token available to unregister"); + return false; + } + + try { + await apiClient.delete("/parent/push-token", { token }); + console.log("Push token unregistered successfully"); + return true; + } catch (error) { + console.error("Failed to unregister push token:", error); + return false; + } +} + +/** + * Set up Android notification channel for alerts + */ +export async function setupNotificationChannels(): Promise { + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("default", { + name: "Default", + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); + + await Notifications.setNotificationChannelAsync("alerts", { + name: "Safety Alerts", + description: "Important safety alerts about your child's device", + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 500, 250, 500], + lightColor: "#FF0000", + sound: "default", + enableVibrate: true, + enableLights: true, + }); + } +} + +/** + * Add a listener for received notifications (when app is foregrounded) + */ +export function addNotificationReceivedListener( + callback: (notification: Notifications.Notification) => void +): Notifications.EventSubscription { + return Notifications.addNotificationReceivedListener(callback); +} + +/** + * Add a listener for notification responses (when user taps notification) + */ +export function addNotificationResponseListener( + callback: (response: Notifications.NotificationResponse) => void +): Notifications.EventSubscription { + return Notifications.addNotificationResponseReceivedListener(callback); +} + +/** + * Get the last notification response (for handling cold start from notification) + */ +export async function getLastNotificationResponse(): Promise { + return Notifications.getLastNotificationResponseAsync(); +} + +/** + * Initialize notifications: setup channels, request permissions, and register token + * Call this when the user is authenticated + */ +export async function initializeNotifications(): Promise<{ + permissionStatus: NotificationPermissionStatus; + tokenRegistered: boolean; +}> { + // Setup Android channels first + await setupNotificationChannels(); + + // Request permissions + const permissionStatus = await requestNotificationPermissions(); + + if (permissionStatus !== "granted") { + return { permissionStatus, tokenRegistered: false }; + } + + // Register token with backend + const tokenRegistered = await registerPushToken(); + + return { permissionStatus, tokenRegistered }; +} diff --git a/lib/theme.ts b/lib/theme.ts new file mode 100644 index 0000000..3d23af3 --- /dev/null +++ b/lib/theme.ts @@ -0,0 +1,24 @@ +export const colors = { + primary: "#F42E2E", + onPrimary: "#F3F3F3", + primaryContainer: "#F42E2E", + onPrimaryContainer: "#F3F3F3", + secondary: "#F42E2E", + onSecondary: "#F3F3F3", + secondaryContainer: "#F42E2E", + onSecondaryContainer: "#F3F3F3", + + background: "#020202", + onBackground: "#F3F3F3", + + surface: "#111112", + onSurface: "#F3F3F3", + + surfaceVariant: "#1A1A1B", + onSurfaceVariant: "#F3F3F3", + + surfaceContainer: "#111112", + surfaceContainerLow: "#0A0A0A", + + outline: "#3A3A3A", +}; diff --git a/lib/ui.tsx b/lib/ui.tsx new file mode 100644 index 0000000..e31a10d --- /dev/null +++ b/lib/ui.tsx @@ -0,0 +1,846 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { ReactNode } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + TextInput as RNTextInput, + ScrollView, + StyleProp, + StyleSheet, + Text, + TextStyle, + TouchableOpacity, + View, + ViewStyle, +} from "react-native"; +import { Device } from "../api/types"; +import { colors } from "./theme"; + +export function Screen({ + children, + contentContainerStyle, +}: { + children: ReactNode; + contentContainerStyle?: StyleProp; +}) { + return ( + + {children} + + ); +} + +export function Card({ + children, + style, +}: { + children: ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +export function H1({ children }: { children: ReactNode }) { + return {children}; +} + +export function H2({ children }: { children: ReactNode }) { + return {children}; +} + +export function Body({ + children, + style, +}: { + children: ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +export function Muted({ + children, + style, +}: { + children: ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +export function Pill({ + label, + tone = "neutral", +}: { + label: string; + tone?: "neutral" | "good" | "attention"; +}) { + const backgroundColor = + tone === "good" + ? colors.surfaceVariant + : tone === "attention" + ? colors.primaryContainer + : colors.surfaceVariant; + + const borderColor = tone === "attention" ? colors.primary : colors.outline; + + const textColor = tone === "attention" ? colors.onPrimary : colors.onSurface; + + return ( + + {label} + + ); +} + +export function Row({ left, right }: { left: ReactNode; right?: ReactNode }) { + return ( + + {left} + {right ? {right} : null} + + ); +} + +export function Divider() { + return ; +} + +export function ActionRow({ + title, + subtitle, + onPress, + right, +}: { + title: string; + subtitle?: string; + onPress?: () => void; + right?: ReactNode; +}) { + return ( + [ + styles.actionRow, + pressed && onPress ? styles.actionRowPressed : undefined, + ]} + > + + {title} + {subtitle ? ( + {subtitle} + ) : null} + + {right ? ( + {right} + ) : null} + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Dialog / Modal Components +// ───────────────────────────────────────────────────────────────────────────── + +export type DialogOption = { + label: string; + onPress: () => void; + destructive?: boolean; +}; + +/** + * A selection dialog that shows a list of options to choose from. + */ +export function SelectDialog({ + visible, + title, + options, + onClose, +}: { + visible: boolean; + title: string; + options: DialogOption[]; + onClose: () => void; +}) { + return ( + + + + {title} + {options.map((option, index) => ( + + + {option.label} + + + ))} + + Cancel + + + + + ); +} + +/** + * A confirmation dialog with a message and OK/Cancel buttons. + */ +export function ConfirmDialog({ + visible, + title, + message, + confirmLabel = "Okay", + cancelLabel = "Cancel", + onConfirm, + onCancel, + destructive = false, +}: { + visible: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; + destructive?: boolean; +}) { + return ( + + + + {title} + {message} + + + {cancelLabel} + + + + {confirmLabel} + + + + + + + ); +} + +/** + * A prompt dialog with an input field, message, and Cancel/Submit buttons. + */ +export function PromptDialog({ + visible, + title, + message, + onClose, + onSubmit, + initialValue = "", +}: { + visible: boolean; + title: string; + message: string; + onClose: () => void; + onSubmit: (value: string) => void; + initialValue?: string; +}) { + const [value, setValue] = React.useState(initialValue); + + React.useEffect(() => { + setValue(initialValue); + }, [initialValue, visible]); + + return ( + + + +

{title}

+ {message} + + + + + +