Skip to content
Open
130 changes: 130 additions & 0 deletions e2e/travel/visa-types.spec.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please refrain from using the locator() method and manually specifying the HTML tags or xpath to locate an element. Please use the getByRole() method instead.

locator() is best used when you are trying to narrow down elements to locate then paired with getByRole()

Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { expect, test } from '@playwright/test';

test('should render visa types landing structure', async ({ page }) => {
await page.goto('/travel/visa-types');

await expect(
page.getByRole('heading', { level: 1, name: 'Philippines Visa Types' })
).toBeVisible();

await expect(
page.getByText(
'Explore different types of visas available for travel to the Philippines'
)
).toBeVisible();

await expect(page.getByPlaceholder('Search visa types...')).toBeVisible();

await expect(
page.getByRole('heading', { name: 'Visa Categories' })
).toBeVisible();

const categoryButtons = page.getByRole('button', { name: /Visas$/ });
expect(await categoryButtons.count()).toBeGreaterThan(0);
await expect(categoryButtons.first()).toBeVisible();

const visaLinks = page.getByRole('link', { name: /Visa/ });
expect(await visaLinks.count()).toBeGreaterThan(0);
await expect(visaLinks.first()).toBeVisible();

await expect(
page.getByRole('heading', { level: 3, name: 'Important Notice' })
).toBeVisible();
});

test('should render visa detail layout structure', async ({ page }) => {
await page.goto('/travel/visa-types');

const visaLinks = page.getByRole('link', { name: /Visa/ });
const firstVisaLink = visaLinks.first();
const targetHref = await firstVisaLink.getAttribute('href');

expect(targetHref).toBeTruthy();

await firstVisaLink.click();
await page.waitForURL(`**${targetHref}`);

await expect(
page.getByRole('heading', { name: 'Philippines Visa Types' })
).toBeVisible();

await expect(page.getByPlaceholder('Search visa types...')).toBeVisible();

await expect(
page.getByRole('link', { name: 'Back to Visa Types' })
).toBeVisible();

const detailHeading = page.getByRole('heading', { level: 2 }).first();
await expect(detailHeading).toBeVisible();
await expect(detailHeading).not.toHaveText('');

await expect(
page.getByRole('heading', { level: 3, name: 'Minimum Requirements' })
).toBeVisible();

const stepsHeading = page.getByRole('heading', { level: 3, name: 'Steps' });
if ((await stepsHeading.count()) > 0) {
await expect(stepsHeading.first()).toBeVisible();
const firstStepTrigger = page
.getByRole('button', { name: /^(Step\s+\d+|\d+\.)/i })
.first();
await firstStepTrigger.click();
const openStepContent = page.locator('[data-state="open"]').first();
await expect(openStepContent).toBeVisible();
const openStepItems = openStepContent.locator('li, p');
expect(await openStepItems.count()).toBeGreaterThan(0);
await expect(openStepItems.first()).toBeVisible();
}

const subtypesHeading = page.getByRole('heading', {
level: 3,
name: 'Visa Subtypes',
});
if ((await subtypesHeading.count()) > 0) {
await expect(subtypesHeading.first()).toBeVisible();
await expect(page.getByRole('heading', { level: 4 }).first()).toBeVisible();
}

await expect(
page.getByRole('heading', { level: 3, name: 'Important Notice' })
).toBeVisible();
});

test('should render 13A visa detail directly', async ({ page }) => {
await page.goto('/travel/visa-types/13a');

await expect(
page.getByRole('heading', { name: 'Philippines Visa Types' })
).toBeVisible();

const primaryHeading13A = page.getByRole('heading', { level: 2 }).first();
await expect(primaryHeading13A).toBeVisible();
await expect(primaryHeading13A).not.toHaveText('');

const minimumRequirementsHeading13A = page.getByRole('heading', {
level: 3,
name: 'Minimum Requirements',
});
await expect(minimumRequirementsHeading13A).toBeVisible();
const minimumRequirementsItems13A = minimumRequirementsHeading13A.locator(
'xpath=following-sibling::div[1]//li'
);
expect(await minimumRequirementsItems13A.count()).toBeGreaterThan(0);
await expect(minimumRequirementsItems13A.first()).toBeVisible();

const stepsHeading = page.getByRole('heading', { level: 3, name: 'Steps' });
await expect(stepsHeading.first()).toBeVisible();
await page
.getByRole('button', { name: /^(Step\s+\d+|\d+\.)/i })
.first()
.click();
const openStepContent13A = page.locator('[data-state="open"]').first();
await expect(openStepContent13A).toBeVisible();
const openStepItems13A = openStepContent13A.locator('li, p');
expect(await openStepItems13A.count()).toBeGreaterThan(0);
await expect(openStepItems13A.first()).toBeVisible();

await expect(
page.getByRole('heading', { level: 3, name: 'Important Notice' })
).toBeVisible();
});
59 changes: 59 additions & 0 deletions src/components/ui/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from 'lucide-react';
import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react';
import { cn } from '../../lib/utils';

const Accordion = AccordionPrimitive.Root;

const AccordionItem = forwardRef<
ElementRef<typeof AccordionPrimitive.Item>,
ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn(
'overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xs',
className
)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';

const AccordionTrigger = forwardRef<
ElementRef<typeof AccordionPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-base font-semibold text-gray-800 transition-colors',
'hover:bg-gray-50 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
'[data-state=open]>svg:rotate-180',
className
)}
{...props}
>
<span className='flex-1'>{children}</span>
<ChevronDownIcon className='h-5 w-5 text-gray-500 transition-transform duration-200' />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;

const AccordionContent = forwardRef<
ElementRef<typeof AccordionPrimitive.Content>,
ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn('px-4 pb-4 pt-0 text-gray-700', className)}
{...props}
>
{children}
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;

export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
80 changes: 80 additions & 0 deletions src/data/visa/philippines_visa_types.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,86 @@
"Birth certificate of Filipino spouse",
"Police clearance from country of origin",
"Medical examination results"
],
"steps": [
{
"title": "Check Eligibility",
"estimated_days": 1,
"items": [
"Foreign spouse is legally married to a Filipino.",
"Entered the Philippines legally (e.g., tourist visa).",
"Filipino spouse is a resident.",
"No criminal record.",
"Applicant’s country must have reciprocity for Filipino immigrants."
]
},
{
"title": "Gather Documents",
"estimated_days": 7,
"items": [
"Consolidated General Application Form (CGAF)",
"Passport bio-page and last entry stamp",
"NBI Clearance (if in PH >6 months, typically takes 7 days)",
"BI Clearance Certificate",
"PSA-issued birth certificate of Filipino spouse",
"Original marriage certificate (PSA or apostilled if abroad)",
"Joint letter request signed by both spouses"
]
},
{
"title": "Submit Application at BI",
"estimated_days": 1,
"items": [
"File the application and required documents at Bureau of Immigration (BI) Main Office or a field office."
]
},
{
"title": "Pre-Screening and Payment of Fees",
"estimated_days": 1,
"items": [
"Documents are reviewed by receiving officer.",
"Pay the visa application and processing fees (PHP 8,620 + ACR I-Card fee USD 50)."
]
},
{
"title": "Attend Interview and Biometrics",
"estimated_days": 7,
"items": [
"Scheduled interview with both spouses at BI.",
"Biometric data capture (fingerprints, photo)."
]
},
{
"title": "Wait for Visa Approval",
"estimated_days": 10,
"items": [
"Application review and final approval (processing time is typically 5-15 working days, may be longer in practice depending on BI workload; many report 2-6 weeks)."
]
},
{
"title": "Visa Issuance and Probationary Residency",
"estimated_days": 1,
"items": [
"Passport is stamped with 13A Probationary Visa.",
"Receive Alien Certificate of Registration Identity Card (ACR I-Card).",
"Probationary status granted for one year."
]
},
{
"title": "Apply for Permanent Residency (after 1 year)",
"estimated_days": 30,
"items": [
"Apply for conversion to permanent 13A status three months before probationary visa expires.",
"Submit updated documents and attend interview if required.",
"Processing for permanent visa may take 30 days or more."
]
}
],
"total_estimated_days_initial_application": 28,
"notes": [
"Actual processing times may vary depending on BI workload and document completeness.",
"Some applicants report longer wait times for approval (up to 2-6 months).",
"Probationary status lasts one year; apply for permanent status before expiration."
]
},
{
Expand Down
81 changes: 81 additions & 0 deletions src/pages/travel/visa-types/[type].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { Link, useParams } from 'react-router-dom';
import visaData from '../../../data/visa/philippines_visa_types.json';
import { VisaType } from '@/types/visa.ts';
import { getCategoryIcon } from './visa.util';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/Accordion';

interface VisaCategory {
id: string;
Expand All @@ -23,6 +29,12 @@ interface VisaTypeDetailParams {
type: string;
}

type StructuredStep = {
title: string;
estimated_days?: number | string;
items?: string[];
};

const VisaTypeDetail: FC = () => {
const { type } = useParams<VisaTypeDetailParams>();
const [visa, setVisa] = useState<VisaType | null>(null);
Expand Down Expand Up @@ -299,6 +311,75 @@ const VisaTypeDetail: FC = () => {
</div>
)}

{Array.isArray(visa.steps) && visa.steps.length > 0 && (
<div className='mb-8'>
<h3 className='text-xl font-semibold text-gray-800 mb-4'>
Steps
</h3>
<Accordion type='multiple' className='space-y-4'>
{visa.steps.map((step, index) => {
const hasStructuredFields =
step &&
typeof step === 'object' &&
'title' in step &&
'items' in step;

const structuredStep = hasStructuredFields
? (step as StructuredStep)
: null;

const accordionValue = `step-${index}`;
const itemKey =
structuredStep?.title ?? accordionValue;
const estimatedDays = structuredStep?.estimated_days;

return (
<AccordionItem key={itemKey} value={accordionValue}>
<AccordionTrigger className='text-base md:text-lg'>
<span className='text-left'>
{structuredStep?.title
? `${index + 1}. ${structuredStep.title}`
: `Step ${index + 1}`}
</span>
</AccordionTrigger>
<AccordionContent>
{structuredStep ? (
<div className='space-y-3 pt-2'>
{estimatedDays && (
<p className='text-sm font-medium text-blue-600 bg-blue-50 inline-flex px-3 py-1 rounded-full'>
Estimated {estimatedDays}
{typeof estimatedDays === 'number'
? estimatedDays === 1
? ' day'
: ' days'
: ''}
</p>
)}

{Array.isArray(structuredStep.items) &&
structuredStep.items.length > 0 && (
<ul className='list-disc pl-5 text-sm text-gray-700 space-y-1'>
{structuredStep.items.map(
(item, itemIndex) => (
<li key={itemIndex}>{item}</li>
)
)}
</ul>
)}
</div>
) : (
<p className='pt-2 text-sm text-gray-700'>
{String(step)}
</p>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
)}

{/* Visa Subtypes */}
{visa.subtypes && visa.subtypes.length > 0 && (
<div className='mb-8'>
Expand Down