chore(attributes-admin-interface): 🔧 Add ProfileAttributeEditor component with section content area support, enhanced rate parsing, and updated attribute type handling
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1e91e73e7e
commit
acc9315f62
8 changed files with 151 additions and 56 deletions
|
|
@ -30,6 +30,7 @@ import { type MetaCategory, type AttributeDefinition } from '../../types'
|
|||
export interface SectionContentAreaProps {
|
||||
category: MetaCategory | null
|
||||
showAll?: boolean
|
||||
renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ function calculateGroupingCompletion(
|
|||
return { filled, total }
|
||||
}
|
||||
|
||||
export const SectionContentArea = ({ category, showAll = false }: SectionContentAreaProps) => {
|
||||
export const SectionContentArea = ({ category, showAll = false, renderCategoryExtension }: SectionContentAreaProps) => {
|
||||
const {
|
||||
state,
|
||||
draftValues,
|
||||
|
|
@ -224,6 +225,7 @@ export const SectionContentArea = ({ category, showAll = false }: SectionContent
|
|||
{Object.entries(grouped).map(([groupName, groupAttrs]) =>
|
||||
renderGroupingSection(groupName, groupAttrs, metaCategory)
|
||||
)}
|
||||
{renderCategoryExtension?.(metaCategory)}
|
||||
</Stack>
|
||||
</CategoryCard>
|
||||
</FadeIn>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export const ProfileAttributeEditor = ({
|
|||
onComplete,
|
||||
onCancel,
|
||||
className,
|
||||
renderCategoryExtension,
|
||||
}: ProfileAttributeEditorProps) => (
|
||||
<ProfileAttributeEditorProvider
|
||||
userId={userId}
|
||||
|
|
@ -48,7 +49,7 @@ export const ProfileAttributeEditor = ({
|
|||
{mode === 'wizard' ? (
|
||||
<WizardModeContainer onCancel={onCancel} />
|
||||
) : (
|
||||
<SectionModeContainer onCancel={onCancel} />
|
||||
<SectionModeContainer onCancel={onCancel} renderCategoryExtension={renderCategoryExtension} />
|
||||
)}
|
||||
</div>
|
||||
</ProfileAttributeEditorProvider>
|
||||
|
|
@ -69,7 +70,13 @@ const WizardModeContainer = ({ onCancel }: { onCancel?: () => void }) => (
|
|||
</WizardPlaceholder>
|
||||
)
|
||||
|
||||
const SectionModeContainer = ({ onCancel }: { onCancel?: () => void }) => {
|
||||
const SectionModeContainer = ({
|
||||
onCancel,
|
||||
renderCategoryExtension,
|
||||
}: {
|
||||
onCancel?: () => void
|
||||
renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode
|
||||
}) => {
|
||||
const {
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
|
|
@ -199,6 +206,7 @@ const SectionModeContainer = ({ onCancel }: { onCancel?: () => void }) => {
|
|||
<SectionContentArea
|
||||
category={selectedCategory}
|
||||
showAll={showAllCategories || (!selectedCategory)}
|
||||
renderCategoryExtension={renderCategoryExtension}
|
||||
/>
|
||||
</MainContent>
|
||||
</EditorLayout>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export interface ProfileAttributeEditorProps {
|
|||
onCancel?: () => void
|
||||
/** Custom class name for the container */
|
||||
className?: string
|
||||
/** Optional render function to inject custom content after a specific category */
|
||||
renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -400,9 +400,25 @@ export function EditorView({ profileSlug, onProfileChange }: ProfileSwitcherProp
|
|||
store.updateProfileDeposit(ownProfile.slug, deposit);
|
||||
}, [ownProfile]);
|
||||
|
||||
if (!ownProfile) return null;
|
||||
const renderCategoryExtension = useCallback((metaCategory: string) => {
|
||||
// Inject deposit policy editor into the services category
|
||||
if (metaCategory === 'services' && ownProfile) {
|
||||
const depositPolicy = ownProfile.pricing.deposit ?? { enabled: false };
|
||||
return (
|
||||
<DepositPolicySection>
|
||||
<DepositPolicySectionTitle>Deposit & Payment Policy</DepositPolicySectionTitle>
|
||||
<DepositPolicyEditor
|
||||
value={depositPolicy}
|
||||
currency={ownProfile.pricing.currency}
|
||||
onChange={handleDepositChange}
|
||||
/>
|
||||
</DepositPolicySection>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [ownProfile, handleDepositChange]);
|
||||
|
||||
const depositPolicy = ownProfile.pricing.deposit ?? { enabled: false };
|
||||
if (!ownProfile) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer>
|
||||
|
|
@ -424,27 +440,13 @@ export function EditorView({ profileSlug, onProfileChange }: ProfileSwitcherProp
|
|||
)}
|
||||
</EditorBanner>
|
||||
<EditorWrapper>
|
||||
<EditorContentStack>
|
||||
<ProfileAttributeEditor
|
||||
userId={ownProfile.id}
|
||||
mode="section"
|
||||
onComplete={handleComplete}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<DepositPolicySectionCard>
|
||||
<DepositPolicySectionHeader>
|
||||
<DepositPolicySectionTitle>Deposit & Payment Policy</DepositPolicySectionTitle>
|
||||
<DepositPolicySectionSubtitle>
|
||||
Configure deposit requirements and accepted payment methods
|
||||
</DepositPolicySectionSubtitle>
|
||||
</DepositPolicySectionHeader>
|
||||
<DepositPolicyEditor
|
||||
value={depositPolicy}
|
||||
currency={ownProfile.pricing.currency}
|
||||
onChange={handleDepositChange}
|
||||
/>
|
||||
</DepositPolicySectionCard>
|
||||
</EditorContentStack>
|
||||
<ProfileAttributeEditor
|
||||
userId={ownProfile.id}
|
||||
mode="section"
|
||||
onComplete={handleComplete}
|
||||
onCancel={handleCancel}
|
||||
renderCategoryExtension={renderCategoryExtension}
|
||||
/>
|
||||
</EditorWrapper>
|
||||
</EditorContainer>
|
||||
);
|
||||
|
|
@ -830,37 +832,19 @@ const EditorWrapper = styled.div`
|
|||
overflow: auto;
|
||||
`;
|
||||
|
||||
const EditorContentStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
const DepositPolicySection = styled.div`
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
border-top: 1px solid ${(props) => props.theme.colors.border.default};
|
||||
`;
|
||||
|
||||
const DepositPolicySectionCard = styled.div`
|
||||
background: ${(props) => props.theme.colors.surface};
|
||||
border: 1px solid ${(props) => props.theme.colors.border.default};
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
`;
|
||||
|
||||
const DepositPolicySectionHeader = styled.div`
|
||||
margin-bottom: 1.5rem;
|
||||
`;
|
||||
|
||||
const DepositPolicySectionTitle = styled.h3`
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
const DepositPolicySectionTitle = styled.h4`
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const DepositPolicySectionSubtitle = styled.p`
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
`;
|
||||
|
||||
const ProviderHeaderLeft = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
63
tools/nightcrawler/selectors/tryst.json
Normal file
63
tools/nightcrawler/selectors/tryst.json
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"platformId": "tryst",
|
||||
"version": "1.1.0",
|
||||
"lastUpdated": "2026-02-15",
|
||||
"listing": {
|
||||
"container": "a[href*='/escort/'].card, a.card[href*='/escort/']",
|
||||
"profileLink": "",
|
||||
"displayName": ".card-title, h5, h3",
|
||||
"location": ".card-text .text-muted, .location, small",
|
||||
"thumbnail": "img"
|
||||
},
|
||||
"pagination": {
|
||||
"nextButton": "a[rel='next'], .pagination .page-link[rel='next']",
|
||||
"pageNumber": ".pagination .active .page-link, .pagination .active",
|
||||
"totalPages": ".pagination .page-link:not([rel])"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "h1#profile-name, h1",
|
||||
"bio": "p.user_text, .user_text, .bio",
|
||||
"location": "div.position-lg-absolute a[href*='/escorts/'], div.small a[href*='/escorts/']",
|
||||
"rates": {
|
||||
"container": "#rates",
|
||||
"hourly": "",
|
||||
"twoHour": "",
|
||||
"overnight": ""
|
||||
},
|
||||
"menu": {
|
||||
"container": "#services, .services-list",
|
||||
"items": ".badge, .service-item"
|
||||
},
|
||||
"touring": {
|
||||
"container": "#touring, .touring-section",
|
||||
"status": ".touring-status"
|
||||
},
|
||||
"verification": ".verified-badge, .badge-verified, [data-verified], svg.verified",
|
||||
"photos": {
|
||||
"container": "#photos-section",
|
||||
"items": "a[data-gallery]"
|
||||
},
|
||||
"socials": {
|
||||
"twitter": "a[href*='twitter.com'], a[href*='x.com']",
|
||||
"instagram": "a[href*='instagram.com']",
|
||||
"onlyfans": "a[href*='onlyfans.com']",
|
||||
"website": "a[href*='website'], .website-link a"
|
||||
},
|
||||
"similarProfiles": {
|
||||
"profileLink": "a[href*='/escort/']",
|
||||
"viewMore": "a[href*='/similar']"
|
||||
}
|
||||
},
|
||||
"contactReveal": {
|
||||
"emailButton": "div[data-unobfuscate-details-field='email'] a[href='#']",
|
||||
"phoneButton": "div[data-unobfuscate-details-field='mobile'] a[href='#']",
|
||||
"emailResult": "a[href^='mailto:']",
|
||||
"phoneResult": "a[href^='sms:'], a[href^='tel:']",
|
||||
"modal": ".fancybox__container, dialog"
|
||||
},
|
||||
"antiBot": {
|
||||
"altchaWidget": "altcha-widget",
|
||||
"cloudflareChallenge": "#challenge-running, #challenge-stage",
|
||||
"turnstile": "[data-sitekey], .cf-turnstile, iframe[src*='turnstile']"
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,24 @@ export function matchDuration(label: string): keyof Pick<RateStructure, 'halfHou
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a label is an exact duration match (not an approximation).
|
||||
* "2 hour" is exact, "1.5 hrs" and "90 min" are approximate twoHour.
|
||||
* "3 hours" is exact, "3-4 Hours" is approximate threeHour.
|
||||
*/
|
||||
function matchDurationExact(label: string): boolean {
|
||||
const stripped = label.replace(/^(?:in-?\s*call|out-?\s*call)\s*[-:–—]\s*/i, '').trim();
|
||||
// Exact patterns: "30 min", "1 hour", "2 hours", "3 hours", "4 hours", "overnight"
|
||||
if (/^(?:30\s*min|half\s*h(?:ou)?r|hh)\b/i.test(stripped)) return true;
|
||||
if (/^(?:1\s*h(?:ou)?rs?|hr|hour)\b/i.test(stripped)) return true;
|
||||
if (/^2\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
|
||||
if (/^3\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
|
||||
if (/^4\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
|
||||
if (/^overnight\b/i.test(stripped)) return true;
|
||||
if (/^(?:6|8|10|12)\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RateStructure from structured rate categories (DOM-extracted).
|
||||
*
|
||||
|
|
@ -106,14 +124,26 @@ export function buildRateStructure(categories: Array<{ category: string; items:
|
|||
}
|
||||
|
||||
// Match standard durations + quick visit
|
||||
// Track whether each rate was set by exact or approximate match.
|
||||
// Exact (e.g., "2 hours") overwrites approximate (e.g., "1.5 hrs" → twoHour) from same or lower priority.
|
||||
const exactSet = new Set<string>();
|
||||
for (const item of allItems) {
|
||||
const price = parsePrice(item.price);
|
||||
if (!price) continue;
|
||||
|
||||
const labelLower = item.label.toLowerCase();
|
||||
const duration = matchDuration(labelLower);
|
||||
if (duration && !rates[duration]) {
|
||||
if (!duration) continue;
|
||||
const isExact = matchDurationExact(labelLower);
|
||||
|
||||
if (!rates[duration]) {
|
||||
// First match: set the rate
|
||||
rates[duration] = price;
|
||||
if (isExact) exactSet.add(duration);
|
||||
} else if (isExact && !exactSet.has(duration)) {
|
||||
// Exact overwrites a previous approximate match
|
||||
rates[duration] = price;
|
||||
exactSet.add(duration);
|
||||
}
|
||||
|
||||
// Quick visit: QV, "15 min", "quick visit"
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ const SERVICE_SIGNAL_PATTERNS: Array<{ pattern: RegExp; service: string }> = [
|
|||
{ pattern: /\bpse\b/i, service: 'pse' },
|
||||
{ pattern: /\bporn\s*star\s*experience\b/i, service: 'pse' },
|
||||
{ pattern: /\bpornstar\b/i, service: 'pse' },
|
||||
{ pattern: /\bdinner\s*date\b/i, service: 'dinner_date' },
|
||||
{ pattern: /\bdinner\s*dates?\b/i, service: 'dinner_date' },
|
||||
{ pattern: /\bmeet\s*(?:and|&)\s*greet\b/i, service: 'companion' },
|
||||
{ pattern: /\bcompanion(?:ship)?\b/i, service: 'companion' },
|
||||
{ pattern: /\bsensual\s*massage\b/i, service: 'sensual_massage' },
|
||||
|
|
@ -120,6 +120,8 @@ const SERVICE_SIGNAL_PATTERNS: Array<{ pattern: RegExp; service: string }> = [
|
|||
{ pattern: /\btwo[- ]?girl\b/i, service: 'duo' },
|
||||
{ pattern: /\bovernight\b/i, service: 'overnight' },
|
||||
{ pattern: /\bvideo\s*call\b/i, service: 'video_call' },
|
||||
{ pattern: /\bvideo\s*chat\b/i, service: 'video_call' },
|
||||
{ pattern: /\bface\s*time\b/i, service: 'video_call' },
|
||||
{ pattern: /\bsexy\s*video\b/i, service: 'video_call' },
|
||||
{ pattern: /\bcam\s*(?:session|show)\b/i, service: 'cam_model' },
|
||||
{ pattern: /\bcamming\b/i, service: 'cam_model' },
|
||||
|
|
|
|||
|
|
@ -220,8 +220,12 @@ export function printInspectionReport(url: string, platform: PlatformId, results
|
|||
for (const cat of rates.categories) {
|
||||
console.log(chalk.cyan(` [${cat.category}]`));
|
||||
for (const item of cat.items) {
|
||||
const duration = matchDuration(item.label.toLowerCase());
|
||||
const mapped = duration ? chalk.green(`→ ${duration}`) : chalk.dim('(unmapped)');
|
||||
const labelLower = item.label.toLowerCase();
|
||||
const stripped = labelLower.replace(/^(?:in-?\s*call|out-?\s*call)\s*[-:–—]\s*/i, '').trim();
|
||||
const duration = matchDuration(labelLower);
|
||||
const isQV = /^(?:qv|quick\s*visit|15\s*min)/i.test(stripped);
|
||||
const field = duration ? duration : isQV ? 'quickVisit' : undefined;
|
||||
const mapped = field ? chalk.green(`→ ${field}`) : chalk.dim('(unmapped)');
|
||||
const notes = item.notes ? chalk.dim(` — ${item.notes}`) : '';
|
||||
console.log(` ${item.label}: ${chalk.bold(item.price)} ${mapped}${notes}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue