ADD - implement Flare dating app

This commit is contained in:
Dominik
2026-08-09 02:40:39 +02:00
parent 4da63ac142
commit 78e84efce1
19 changed files with 4228 additions and 1 deletions
+151
View File
@@ -0,0 +1,151 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFlareStore } from '@/stores/flare'
import type {
FlareBootstrap,
FlareLike,
FlareMatch,
FlareProfile,
FlareProfileDraft,
} from '@/types/flare'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const maya: FlareProfile = {
age: 26,
avatar: 0,
bio: 'Ocean air and rooftop sunsets.',
gender: 'woman',
id: 11,
interests: ['Beach days', 'Art'],
lookingFor: 'longTerm',
name: 'Maya',
photoUrls: [],
}
const mayaLike: FlareLike = { ...maya, superLiked: true }
const bootstrap: FlareBootstrap = {
likes: [mayaLike],
matches: [],
profile: {
age: 27,
avatar: 5,
bio: 'Late-night drives and good coffee.',
discoverable: true,
gender: 'nonbinary',
id: 1,
interestedIn: 'everyone',
interests: ['Music', 'Coffee'],
lookingFor: 'dates',
maxAge: 39,
minAge: 21,
name: 'Alex',
photoMediaIds: [],
photoUrls: [],
},
suggestions: [maya],
}
describe('flare store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads suggestions, incoming likes and own discovery state', async () => {
mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true })
const flare = useFlareStore()
expect(await flare.bootstrap()).toBe(true)
expect(flare.profile?.discoverable).toBe(true)
expect(flare.likes).toEqual([mayaLike])
expect(flare.suggestions).toEqual([maya])
})
it('keeps matches available when Discovery is turned off', async () => {
const hidden = {
...bootstrap,
profile: { ...bootstrap.profile!, discoverable: false },
suggestions: [],
}
mockNuiCall.mockResolvedValueOnce({ data: hidden, success: true })
const flare = useFlareStore()
expect(await flare.setDiscovery(false)).toBe(true)
expect(flare.profile?.discoverable).toBe(false)
expect(flare.suggestions).toEqual([])
expect(mockNuiCall).toHaveBeenCalledWith('flare:set-discovery', {
enabled: false,
})
})
it('sends only Gallery media ids when profile photos are saved', async () => {
const updated = {
...bootstrap,
profile: {
...bootstrap.profile!,
photoMediaIds: [42],
photoUrls: ['https://cdn.example.test/profile.jpg'],
},
}
const draft: FlareProfileDraft = {
age: bootstrap.profile!.age,
avatar: bootstrap.profile!.avatar,
bio: bootstrap.profile!.bio,
gender: bootstrap.profile!.gender,
interestedIn: bootstrap.profile!.interestedIn,
interests: [...bootstrap.profile!.interests],
lookingFor: bootstrap.profile!.lookingFor,
maxAge: bootstrap.profile!.maxAge,
minAge: bootstrap.profile!.minAge,
name: bootstrap.profile!.name,
photoMediaIds: [42],
}
mockNuiCall.mockResolvedValueOnce({ data: updated, success: true })
const flare = useFlareStore()
expect(await flare.saveProfile(draft)).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('flare:save-profile', draft)
expect(flare.profile?.photoUrls).toEqual([
'https://cdn.example.test/profile.jpg',
])
})
it('uses a real Super Like and removes the target from both decks', async () => {
const match: FlareMatch = {
id: 'match-1',
lastMessage: '',
lastMessageAt: null,
profile: maya,
unread: 0,
}
mockNuiCall.mockResolvedValueOnce({ data: { match }, success: true })
const flare = useFlareStore()
flare.suggestions = [maya]
flare.likes = [mayaLike]
expect(await flare.swipe(maya.id, 'superlike')).toEqual(match)
expect(flare.suggestions).toEqual([])
expect(flare.likes).toEqual([])
expect(flare.matches).toEqual([match])
expect(mockNuiCall).toHaveBeenCalledWith('flare:swipe', {
choice: 'superlike',
targetId: maya.id,
})
})
it('preserves the previous discovery state when the server rejects a toggle', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'invalid_discovery',
success: false,
})
const flare = useFlareStore()
flare.profile = bootstrap.profile
expect(await flare.setDiscovery(false)).toBe(false)
expect(flare.profile?.discoverable).toBe(true)
expect(flare.error).toBe('invalid_discovery')
})
})
+137
View File
@@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import type {
FlareBootstrap,
FlareMatch,
FlareMessage,
FlareProfile,
FlareProfileDraft,
} from '@/types/flare'
import { nuiCall } from '@/utils/nui'
export const useFlareStore = defineStore('flare', {
state: () => ({
activeMatchId: '' as string,
error: '' as string,
loading: false,
likes: [] as FlareBootstrap['likes'],
matches: [] as FlareMatch[],
messages: [] as FlareMessage[],
profile: null as FlareBootstrap['profile'],
sending: false,
suggestions: [] as FlareProfile[],
}),
actions: {
async bootstrap(): Promise<boolean> {
this.loading = true
const response = await nuiCall<FlareBootstrap>('flare:bootstrap')
this.loading = false
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success && response.data) {
this.applyBootstrap(response.data)
}
return response.success
},
async loadThread(matchId: string): Promise<boolean> {
this.activeMatchId = matchId
const response = await nuiCall<{ messages: FlareMessage[] }>(
'flare:thread',
{ matchId },
)
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success) {
this.messages = response.data?.messages ?? []
const match = this.matches.find((item) => item.id === matchId)
if (match) match.unread = 0
}
return response.success
},
async saveProfile(draft: FlareProfileDraft): Promise<boolean> {
const response = await nuiCall<FlareBootstrap>(
'flare:save-profile',
draft,
)
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success && response.data) {
this.applyBootstrap(response.data)
}
return response.success
},
async send(matchId: string, body: string): Promise<boolean> {
this.sending = true
const response = await nuiCall<FlareMessage>('flare:send', {
body,
matchId,
})
this.sending = false
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success && response.data) {
this.messages.push(response.data)
const match = this.matches.find((item) => item.id === matchId)
if (match) {
match.lastMessage = response.data.body
match.lastMessageAt = response.data.createdAt
}
}
return response.success
},
async swipe(
targetId: number,
choice: 'like' | 'pass' | 'superlike',
): Promise<FlareMatch | null> {
const response = await nuiCall<{ match: FlareMatch | null }>(
'flare:swipe',
{ choice, targetId },
)
this.error = response.success ? '' : (response.error ?? 'default')
if (!response.success) return null
this.suggestions = this.suggestions.filter(
(profile) => profile.id !== targetId,
)
this.likes = this.likes.filter((profile) => profile.id !== targetId)
const match = response.data?.match ?? null
if (match) {
this.matches = [
match,
...this.matches.filter((item) => item.id !== match.id),
]
}
return match
},
async rewind(): Promise<boolean> {
const response = await nuiCall<FlareBootstrap>('flare:rewind')
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success && response.data) this.applyBootstrap(response.data)
return response.success
},
async setDiscovery(enabled: boolean): Promise<boolean> {
const response = await nuiCall<FlareBootstrap>('flare:set-discovery', {
enabled,
})
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success && response.data) this.applyBootstrap(response.data)
return response.success
},
async unmatch(matchId: string): Promise<boolean> {
const response = await nuiCall<{ matches: FlareMatch[] }>(
'flare:unmatch',
{
matchId,
},
)
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success) {
this.matches = response.data?.matches ?? []
this.messages = []
if (this.activeMatchId === matchId) this.activeMatchId = ''
}
return response.success
},
applyBootstrap(data: FlareBootstrap): void {
this.profile = data.profile
this.likes = data.likes
this.matches = data.matches
this.suggestions = data.suggestions
},
},
})
+133
View File
@@ -28,6 +28,139 @@ const namespaceQueues = new Map<string, Promise<void>>()
const defaultLocales: LocaleTree = {
Apps: {
flare: {
name: 'Flare',
signInTitle: 'Sign in to Flare',
signInBody:
'Flare profiles and matches are linked to your private iFruit account.',
createProfile: 'Create your profile',
welcome: 'Find your spark',
welcomeBody: 'Build a profile and meet people across Los Santos.',
photo: 'Profile photo',
profilePhotos: 'Profile photos',
profilePhotosBody:
'Choose up to six photos from Gallery. Your first photo is shown first.',
addPhotos: 'Add photos',
choosePhotos: 'Choose from Gallery',
primaryPhoto: 'Main',
removePhoto: 'Remove photo {number}',
yourName: 'Your name',
namePlaceholder: 'What should people call you?',
age: 'Age',
bio: 'About you',
bioPlaceholder: 'A short line that starts a conversation...',
gender: 'I am',
showMe: 'Show me',
woman: 'Woman',
man: 'Man',
nonbinary: 'Non-binary',
women: 'Women',
men: 'Men',
nonbinaryPeople: 'Non-binary people',
everyone: 'Everyone',
minimumAge: 'Minimum age',
maximumAge: 'Maximum age',
start: 'Start exploring',
saveProfile: 'Save profile',
editProfileBody: 'Keep your profile fresh and unmistakably you.',
navigation: 'Flare navigation',
discover: 'Discover',
matches: 'Matches',
profile: 'Profile',
tabs: {
discover: 'Swipe',
explore: 'Explore',
likes: 'Likes',
matches: 'Chat',
profile: 'Profile',
},
filters: 'Discovery settings',
nearby: 'Nearby',
rewind: 'Rewind',
superLike: 'Super Like',
explore: 'Explore',
clearExploreFilter: 'Show all profiles',
exploreTitle: 'Explore',
exploreBody: 'Find people who are into the same things.',
exploreModes: {
forYou: 'For You',
dateNight: 'Date Night',
freeTonight: 'Free Tonight',
longTerm: 'Long-term thing',
newFriends: 'New Friends',
weekend: 'Weekend plans',
},
likesTitle: 'Likes You',
likesBody: 'See who already likes you, then make your choice.',
matched: 'Matched',
newMatches: 'New Matches',
messages: 'Messages',
settings: 'Settings',
editProfile: 'Edit profile',
profileGoalBody: 'Shown on your discovery card.',
relationshipGoal: 'Relationship goal',
interests: 'Interests',
interestsPlaceholder: 'Music, travel, coffee',
interestsHint: 'Separate up to five interests with commas.',
discoverySettings: 'Discovery',
discoveryPreferences: 'Who you want to meet',
showProfile: 'Show me on Flare',
showProfileBody: 'Let new people discover your profile.',
discoveryPrivacyNote:
'Turning Discovery off hides you from new people. Your existing matches and chats stay available.',
discoveryOffTitle: 'Discovery is off',
discoveryOffBody:
'Your profile is hidden from new people. Your existing matches and chats stay available.',
discoveryOffShort: 'Only existing matches can still reach you.',
enableDiscovery: 'Enable Discovery',
saveSettings: 'Save settings',
likedYou: 'Likes you',
superLikedYou: 'Super Liked you',
noLikes: 'No new likes',
noLikesBody: 'People who like you before you decide will appear here.',
matchActions: 'Match actions',
unmatch: 'Unmatch',
unmatchTitle: 'Unmatch this person?',
unmatchBody:
'You and {name} will disappear from each others match lists. This cannot be undone.',
like: 'Like',
pass: 'Pass',
noProfiles: "You're all caught up",
noProfilesBody: 'Come back later when new people join Flare.',
yourMatches: 'Your matches',
matchesBody: 'A spark goes both ways.',
newMatch: 'You matched — say hello!',
noMatches: 'No sparks yet',
noMatchesBody: 'When someone likes you back, they will appear here.',
messagePlaceholder: 'Write a message',
itsAMatch: "It's a spark!",
matchBody: 'You and {name} liked each other.',
sayHello: 'Say hello',
keepSwiping: 'Keep exploring',
newMatchNotification: 'You and {sender} found a spark!',
newMessageNotification: 'New message from {sender}',
lookingFor: {
longTerm: 'Long-term connection',
dates: 'Good dates',
friends: 'New friends',
},
errors: {
invalid_profile: 'Check your name, age and profile text.',
invalid_profile_photos:
'Choose up to six photos from your own Gallery.',
request_failed: 'Flare could not save those changes. Try again.',
invalid_target: 'This profile is no longer available.',
invalid_choice: 'That swipe could not be saved.',
invalid_discovery: 'That Discovery setting is invalid.',
discovery_disabled: 'Enable Discovery before swiping.',
nothing_to_rewind: 'There is no recent swipe to rewind.',
cannot_rewind_match: 'A swipe that created a match cannot be rewound.',
match_not_found: 'This match is no longer available.',
invalid_message: 'Write a message before sending.',
rate_limited: 'Slow down for a moment and try again.',
default: 'Flare could not complete the request.',
},
},
darkchat: {
name: 'DarkChat',
newMessage: 'New DarkChat message from {sender}',